mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-20 09:20:16 +00:00
Initial Move from Azure DevOps to GitHub
This commit is contained in:
153
src/Explorer/Tabs/ConflictsTab.html
Normal file
153
src/Explorer/Tabs/ConflictsTab.html
Normal file
@@ -0,0 +1,153 @@
|
||||
<div
|
||||
class="tab-pane active tabdocuments flexContainer"
|
||||
data-bind="
|
||||
setTemplateReady: true,
|
||||
attr:{
|
||||
id: tabId
|
||||
},
|
||||
visible: isActive"
|
||||
role="tabpanel"
|
||||
>
|
||||
<!-- Ids and Editor - Start -->
|
||||
<div class="documentsTabGridAndEditor documentsTabGridAndEditorUpperPadding">
|
||||
<div class="documentsContainerWithSplitter" , data-bind="attr: { id: documentContentsContainerId }">
|
||||
<div class="flexContainer">
|
||||
<div class="documentsGridHeaderContainer">
|
||||
<!-- ko if: !partitionKeyProperty -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="documentsGridHeader">id</td>
|
||||
<td class="refreshColHeader">
|
||||
<img
|
||||
class="refreshcol"
|
||||
src="/refresh-cosmos.svg"
|
||||
data-bind="click: refreshDocumentsGrid"
|
||||
alt="Refresh documents"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko if: partitionKeyProperty -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="documentsGridHeader evenlySpacedHeader">id</td>
|
||||
<td
|
||||
class="documentsGridHeader documentsGridPartition evenlySpacedHeader"
|
||||
data-bind="
|
||||
attr: {
|
||||
title: partitionKeyPropertyHeader
|
||||
},
|
||||
text: partitionKeyPropertyHeader"
|
||||
></td>
|
||||
<td
|
||||
class="refreshColHeader"
|
||||
role="button"
|
||||
aria-label="Refresh documents"
|
||||
tabindex="0"
|
||||
data-bind="event: { keydown: onRefreshButtonKeyDown }"
|
||||
>
|
||||
<img
|
||||
class="refreshcol"
|
||||
src="/refresh-cosmos.svg"
|
||||
data-bind="click: refreshDocumentsGrid"
|
||||
alt="Refresh documents"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
<!-- Document Ids - Start -->
|
||||
<div
|
||||
class="tabdocuments scrollable"
|
||||
data-bind="
|
||||
attr: {
|
||||
id: documentContentsGridId,
|
||||
tabindex: conflictIds().length <= 0 ? -1 : 0
|
||||
},
|
||||
style: { height: dataContentsGridScrollHeight },
|
||||
event: { keydown: accessibleDocumentList.onKeyDown }"
|
||||
>
|
||||
<table class="table table-hover" data-bind="css: { 'can-select': false, 'dataTable': false }">
|
||||
<tbody id="tbodycontent">
|
||||
<!-- ko foreach: conflictIds -->
|
||||
<tr
|
||||
class="pointer accessibleListElement"
|
||||
data-bind="
|
||||
click: $data.click,
|
||||
css: {
|
||||
gridRowSelected: $parent.selectedConflictId && $parent.selectedConflictId() && $parent.selectedConflictId().rid === $data.rid,
|
||||
gridRowHighlighted: $parent.accessibleDocumentList.currentItem() && $parent.accessibleDocumentList.currentItem().rid === $data.rid
|
||||
}"
|
||||
>
|
||||
<td class="tabdocumentsGridElement"><a data-bind="text: $data.id, attr: { title: $data.id }"></a></td>
|
||||
<!-- ko if: $data.partitionKeyProperty -->
|
||||
<td class="tabdocumentsGridElement">
|
||||
<a
|
||||
data-bind="text: $data.stringPartitionKeyValue, attr: { title: $data.stringPartitionKeyValue }"
|
||||
></a>
|
||||
</td>
|
||||
<!-- /ko -->
|
||||
</tr>
|
||||
<!-- /ko -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="loadMore">
|
||||
<a role="link" data-bind="click: loadNextPage, event: { keypress: onLoadMoreKeyInput }" tabindex="0"
|
||||
>Load more</a
|
||||
>
|
||||
</div>
|
||||
<!-- Document Ids - End -->
|
||||
<!-- Splitter -->
|
||||
</div>
|
||||
<div class="splitter ui-resizable-handle ui-resizable-e colResizePointer" id="h_splitter2"></div>
|
||||
</div>
|
||||
<div class="documentWaterMark" data-bind="visible: shouldShowWatermark">
|
||||
<p><img src="/DocumentWaterMark.svg" alt="Document WaterMark" /></p>
|
||||
<p class="documentWaterMarkText">View and resolve conflicts</p>
|
||||
</div>
|
||||
<!-- Editor - Start -->
|
||||
<div class="conflictEditorContainer" data-bind="visible: !shouldShowWatermark()">
|
||||
<div class="conflictEditorHeader">
|
||||
<div data-bind="visible: conflictOperation() === 'replace'">
|
||||
<div class="conflictEditorHeaderLabel">Current document</div>
|
||||
<div class="conflictEditorHeaderLabel">Conflict update</div>
|
||||
</div>
|
||||
<div data-bind="visible: conflictOperation() === 'create'">
|
||||
<div class="conflictEditorHeaderLabel">Conflict insert</div>
|
||||
</div>
|
||||
<div data-bind="visible: conflictOperation() === 'delete'">
|
||||
<div class="conflictEditorHeaderLabel">Conflict delete</div>
|
||||
</div>
|
||||
</div>
|
||||
<diff-editor
|
||||
class="editorDivContent"
|
||||
data-bind="visible: shouldShowDiffEditor"
|
||||
params="{
|
||||
originalContent: selectedConflictCurrent,
|
||||
modifiedContent: selectedConflictContent,
|
||||
lineNumbers: 'on',
|
||||
ariaLabel: 'Conflict editor',
|
||||
updatedContent: selectedConflictContent}"
|
||||
></diff-editor>
|
||||
<json-editor
|
||||
class="editorDivContent"
|
||||
data-bind="visible: shouldShowEditor"
|
||||
params="{
|
||||
content: selectedConflictContent,
|
||||
lineNumbers: 'on',
|
||||
ariaLabel: 'Conflict editor',
|
||||
updatedContent: selectedConflictContent}"
|
||||
></json-editor>
|
||||
</div>
|
||||
<!-- Editor - End -->
|
||||
</div>
|
||||
<!-- Ids and Editor - End -->
|
||||
</div>
|
||||
716
src/Explorer/Tabs/ConflictsTab.ts
Normal file
716
src/Explorer/Tabs/ConflictsTab.ts
Normal file
@@ -0,0 +1,716 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { AccessibleVerticalList } from "../Tree/AccessibleVerticalList";
|
||||
import { KeyCodes } from "../../Common/Constants";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import ConflictId from "../Tree/ConflictId";
|
||||
import editable from "../../Common/EditableUtility";
|
||||
import * as HeadersUtility from "../../Common/HeadersUtility";
|
||||
import TabsBase from "./TabsBase";
|
||||
import { DocumentsGridMetrics } from "../../Common/Constants";
|
||||
import { Splitter, SplitterBounds, SplitterDirection } from "../../Common/Splitter";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import Toolbar from "../Controls/Toolbar/Toolbar";
|
||||
import SaveIcon from "../../../images/save-cosmos.svg";
|
||||
import DiscardIcon from "../../../images/discard.svg";
|
||||
import DeleteIcon from "../../../images/delete.svg";
|
||||
import { QueryIterator, ItemDefinition, Resource, ConflictDefinition } from "@azure/cosmos";
|
||||
import { MinimalQueryIterator } from "../../Common/IteratorUtilities";
|
||||
|
||||
export class ConflictsTab extends TabsBase implements ViewModels.ConflictsTab {
|
||||
public selectedConflictId: ko.Observable<ViewModels.ConflictId>;
|
||||
public selectedConflictContent: ViewModels.Editable<string>;
|
||||
public selectedConflictCurrent: ViewModels.Editable<string>;
|
||||
public documentContentsGridId: string;
|
||||
public documentContentsContainerId: string;
|
||||
public isEditorDirty: ko.Computed<boolean>;
|
||||
public editorState: ko.Observable<ViewModels.DocumentExplorerState>;
|
||||
public toolbarViewModel = ko.observable<Toolbar>();
|
||||
public acceptChangesButton: ViewModels.Button;
|
||||
public discardButton: ViewModels.Button;
|
||||
public deleteButton: ViewModels.Button;
|
||||
public accessibleDocumentList: AccessibleVerticalList;
|
||||
public dataContentsGridScrollHeight: ko.Observable<string>;
|
||||
public shouldShowDiffEditor: ko.Computed<boolean>;
|
||||
public shouldShowEditor: ko.Computed<boolean>;
|
||||
public shouldShowWatermark: ko.Computed<boolean>;
|
||||
public loadingConflictData: ko.Observable<boolean> = ko.observable<boolean>(false);
|
||||
public isEditorReadOnly: ko.Computed<boolean>;
|
||||
public splitter: Splitter;
|
||||
|
||||
public partitionKey: DataModels.PartitionKey;
|
||||
public partitionKeyPropertyHeader: string;
|
||||
public partitionKeyProperty: string;
|
||||
public conflictOperation: ko.Observable<string> = ko.observable<string>();
|
||||
public conflictIds: ko.ObservableArray<ViewModels.ConflictId>;
|
||||
|
||||
private _documentsIterator: MinimalQueryIterator;
|
||||
private _container: ViewModels.Explorer;
|
||||
private _acceptButtonLabel: ko.Observable<string> = ko.observable("Save");
|
||||
protected _selfLink: string;
|
||||
|
||||
constructor(options: ViewModels.ConflictsTabOptions) {
|
||||
super(options);
|
||||
this._container = options.collection && options.collection.container;
|
||||
|
||||
this.documentContentsGridId = `conflictsContentsGrid${this.tabId}`;
|
||||
this.documentContentsContainerId = `conflictsContentsContainer${this.tabId}`;
|
||||
this.editorState = ko.observable<ViewModels.DocumentExplorerState>(
|
||||
ViewModels.DocumentExplorerState.noDocumentSelected
|
||||
);
|
||||
this.selectedConflictId = ko.observable<ViewModels.ConflictId>();
|
||||
this.selectedConflictContent = editable.observable<any>("");
|
||||
this.selectedConflictCurrent = editable.observable<any>("");
|
||||
this.partitionKey = options.partitionKey || (this.collection && this.collection.partitionKey);
|
||||
this.conflictIds = options.conflictIds;
|
||||
this._selfLink = options.selfLink || (this.collection && this.collection.self);
|
||||
this.partitionKeyPropertyHeader =
|
||||
(this.collection && this.collection.partitionKeyPropertyHeader) || this._getPartitionKeyPropertyHeader();
|
||||
this.partitionKeyProperty = !!this.partitionKeyPropertyHeader
|
||||
? this.partitionKeyPropertyHeader
|
||||
.replace(/[/]+/g, ".")
|
||||
.substr(1)
|
||||
.replace(/[']+/g, "")
|
||||
: null;
|
||||
|
||||
this.dataContentsGridScrollHeight = ko.observable<string>(null);
|
||||
|
||||
// initialize splitter only after template has been loaded so dom elements are accessible
|
||||
super.onTemplateReady((isTemplateReady: boolean) => {
|
||||
if (isTemplateReady) {
|
||||
const tabContainer: HTMLElement = document.getElementById("content");
|
||||
const splitterBounds: SplitterBounds = {
|
||||
min: Constants.DocumentsGridMetrics.DocumentEditorMinWidthRatio * tabContainer.clientWidth,
|
||||
max: Constants.DocumentsGridMetrics.DocumentEditorMaxWidthRatio * tabContainer.clientWidth
|
||||
};
|
||||
this.splitter = new Splitter({
|
||||
splitterId: "h_splitter2",
|
||||
leftId: this.documentContentsContainerId,
|
||||
bounds: splitterBounds,
|
||||
direction: SplitterDirection.Vertical
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.accessibleDocumentList = new AccessibleVerticalList(this.conflictIds());
|
||||
this.accessibleDocumentList.setOnSelect(
|
||||
(selectedDocument: ViewModels.ConflictId) => selectedDocument && selectedDocument.click()
|
||||
);
|
||||
this.selectedConflictId.subscribe((newSelectedDocumentId: ViewModels.ConflictId) => {
|
||||
this.accessibleDocumentList.updateCurrentItem(newSelectedDocumentId);
|
||||
this.conflictOperation(newSelectedDocumentId && newSelectedDocumentId.operationType);
|
||||
});
|
||||
|
||||
this.conflictIds.subscribe((newDocuments: ViewModels.ConflictId[]) => {
|
||||
this.accessibleDocumentList.updateItemList(newDocuments);
|
||||
this.dataContentsGridScrollHeight(
|
||||
newDocuments.length * DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px"
|
||||
);
|
||||
});
|
||||
|
||||
this.isEditorDirty = ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.noDocumentSelected:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
return false;
|
||||
|
||||
case ViewModels.DocumentExplorerState.newDocumentValid:
|
||||
case ViewModels.DocumentExplorerState.newDocumentInvalid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
|
||||
return true;
|
||||
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
return (
|
||||
this.selectedConflictCurrent.getEditableOriginalValue() !==
|
||||
this.selectedConflictCurrent.getEditableCurrentValue()
|
||||
);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.acceptChangesButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return this.conflictOperation() !== Constants.ConflictOperationType.Delete || !!this.selectedConflictContent();
|
||||
})
|
||||
};
|
||||
|
||||
this.discardButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return this.conflictOperation() !== Constants.ConflictOperationType.Delete || !!this.selectedConflictContent();
|
||||
})
|
||||
};
|
||||
|
||||
this.deleteButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.buildCommandBarOptions();
|
||||
this.shouldShowDiffEditor = ko.pureComputed<boolean>(() => {
|
||||
const documentHasContent: boolean =
|
||||
this.selectedConflictContent() != null && this.selectedConflictContent().length > 0;
|
||||
const operationIsReplace: boolean = this.conflictOperation() === Constants.ConflictOperationType.Replace;
|
||||
const isLoadingData: boolean = this.loadingConflictData();
|
||||
return documentHasContent && operationIsReplace && !isLoadingData;
|
||||
});
|
||||
|
||||
this.shouldShowEditor = ko.pureComputed<boolean>(() => {
|
||||
const documentHasContent: boolean =
|
||||
this.selectedConflictContent() != null && this.selectedConflictContent().length > 0;
|
||||
const operationIsInsert: boolean = this.conflictOperation() === Constants.ConflictOperationType.Create;
|
||||
const operationIsDelete: boolean = this.conflictOperation() === Constants.ConflictOperationType.Delete;
|
||||
const isLoadingData: boolean = this.loadingConflictData();
|
||||
return documentHasContent && (operationIsInsert || operationIsDelete) && !isLoadingData;
|
||||
});
|
||||
|
||||
this.shouldShowWatermark = ko.pureComputed<boolean>(() => !this.shouldShowDiffEditor() && !this.shouldShowEditor());
|
||||
|
||||
this.isEditorReadOnly = ko.pureComputed<boolean>(() => {
|
||||
const operationIsDelete: boolean = this.conflictOperation() === Constants.ConflictOperationType.Delete;
|
||||
const operationIsReplace: boolean = this.conflictOperation() === Constants.ConflictOperationType.Replace;
|
||||
return operationIsDelete || operationIsReplace;
|
||||
});
|
||||
|
||||
this.selectedConflictContent.subscribe((newContent: string) => this._onEditorContentChange(newContent));
|
||||
|
||||
this.conflictOperation.subscribe((newOperationType: string) => {
|
||||
let operationLabel = "Save";
|
||||
if (newOperationType === Constants.ConflictOperationType.Replace) {
|
||||
operationLabel = "Update";
|
||||
}
|
||||
|
||||
this._acceptButtonLabel(operationLabel);
|
||||
});
|
||||
}
|
||||
|
||||
public refreshDocumentsGrid(): Q.Promise<any> {
|
||||
// clear documents grid
|
||||
this.conflictIds([]);
|
||||
return this.createIterator()
|
||||
.then(
|
||||
// reset iterator
|
||||
iterator => {
|
||||
this._documentsIterator = iterator;
|
||||
}
|
||||
)
|
||||
.then(
|
||||
// load documents
|
||||
() => {
|
||||
return this.loadNextPage();
|
||||
}
|
||||
)
|
||||
.catch(reason => {
|
||||
const message = ErrorParserUtility.parse(reason)[0].message;
|
||||
window.alert(message);
|
||||
});
|
||||
}
|
||||
|
||||
public onRefreshButtonKeyDown = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.refreshDocumentsGrid();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public onConflictIdClick(clickedDocumentId: ViewModels.ConflictId): Q.Promise<any> {
|
||||
if (this.editorState() !== ViewModels.DocumentExplorerState.noDocumentSelected) {
|
||||
return Q();
|
||||
}
|
||||
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onAcceptChangesClick = (): Q.Promise<any> => {
|
||||
if (this.isEditorDirty() && !this._isIgnoreDirtyEditor()) {
|
||||
return Q();
|
||||
}
|
||||
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
|
||||
const selectedConflict = this.selectedConflictId();
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.ResolveConflict, {
|
||||
databaseAccountName: this._container.databaseAccount().name,
|
||||
defaultExperience: this._container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
conflictResourceType: selectedConflict.resourceType,
|
||||
conflictOperationType: selectedConflict.operationType,
|
||||
conflictResourceId: selectedConflict.resourceId
|
||||
});
|
||||
|
||||
let operationPromise: Q.Promise<any> = Q();
|
||||
if (selectedConflict.operationType === Constants.ConflictOperationType.Replace) {
|
||||
const documentContent = JSON.parse(this.selectedConflictContent());
|
||||
|
||||
operationPromise = this._container.documentClientUtility.updateDocument(
|
||||
this.collection,
|
||||
selectedConflict.buildDocumentIdFromConflict(documentContent[selectedConflict.partitionKeyProperty]),
|
||||
documentContent
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedConflict.operationType === Constants.ConflictOperationType.Create) {
|
||||
const documentContent = JSON.parse(this.selectedConflictContent());
|
||||
|
||||
operationPromise = this._container.documentClientUtility.createDocument(this.collection, documentContent);
|
||||
}
|
||||
|
||||
if (selectedConflict.operationType === Constants.ConflictOperationType.Delete && !!this.selectedConflictContent()) {
|
||||
const documentContent = JSON.parse(this.selectedConflictContent());
|
||||
|
||||
operationPromise = this._container.documentClientUtility.deleteDocument(
|
||||
this.collection,
|
||||
selectedConflict.buildDocumentIdFromConflict(documentContent[selectedConflict.partitionKeyProperty])
|
||||
);
|
||||
}
|
||||
|
||||
return operationPromise
|
||||
.then(
|
||||
() => {
|
||||
return this._container.documentClientUtility.deleteConflict(this.collection, selectedConflict).then(() => {
|
||||
this.conflictIds.remove((conflictId: ViewModels.ConflictId) => conflictId.rid === selectedConflict.rid);
|
||||
this.selectedConflictContent("");
|
||||
this.selectedConflictCurrent("");
|
||||
this.selectedConflictId(null);
|
||||
this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.ResolveConflict,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
conflictResourceType: selectedConflict.resourceType,
|
||||
conflictOperationType: selectedConflict.operationType,
|
||||
conflictResourceId: selectedConflict.resourceId
|
||||
},
|
||||
startKey
|
||||
);
|
||||
});
|
||||
},
|
||||
reason => {
|
||||
this.isExecutionError(true);
|
||||
const message = ErrorParserUtility.parse(reason)[0].message;
|
||||
window.alert(message);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.ResolveConflict,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
conflictResourceType: selectedConflict.resourceType,
|
||||
conflictOperationType: selectedConflict.operationType,
|
||||
conflictResourceId: selectedConflict.resourceId
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public onDeleteClick = (): Q.Promise<any> => {
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
|
||||
const selectedConflict = this.selectedConflictId();
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteConflict, {
|
||||
databaseAccountName: this._container.databaseAccount().name,
|
||||
defaultExperience: this._container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
conflictResourceType: selectedConflict.resourceType,
|
||||
conflictOperationType: selectedConflict.operationType,
|
||||
conflictResourceId: selectedConflict.resourceId
|
||||
});
|
||||
|
||||
return this._container.documentClientUtility
|
||||
.deleteConflict(this.collection, selectedConflict)
|
||||
.then(
|
||||
() => {
|
||||
this.conflictIds.remove((conflictId: ViewModels.ConflictId) => conflictId.rid === selectedConflict.rid);
|
||||
this.selectedConflictContent("");
|
||||
this.selectedConflictCurrent("");
|
||||
this.selectedConflictId(null);
|
||||
this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.DeleteConflict,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
conflictResourceType: selectedConflict.resourceType,
|
||||
conflictOperationType: selectedConflict.operationType,
|
||||
conflictResourceId: selectedConflict.resourceId
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
reason => {
|
||||
this.isExecutionError(true);
|
||||
const message = ErrorParserUtility.parse(reason)[0].message;
|
||||
window.alert(message);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.DeleteConflict,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
conflictResourceType: selectedConflict.resourceType,
|
||||
conflictOperationType: selectedConflict.operationType,
|
||||
conflictResourceId: selectedConflict.resourceId
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public onDiscardClick = (): Q.Promise<any> => {
|
||||
this.selectedConflictContent(this.selectedConflictContent.getEditableOriginalValue());
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
|
||||
return Q();
|
||||
};
|
||||
|
||||
public onValidDocumentEdit(): Q.Promise<any> {
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid);
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onInvalidDocumentEdit(): Q.Promise<any> {
|
||||
if (
|
||||
this.editorState() === ViewModels.DocumentExplorerState.exisitingDocumentNoEdits ||
|
||||
this.editorState() === ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid
|
||||
) {
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid);
|
||||
return Q();
|
||||
}
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onTabClick(): Q.Promise<any> {
|
||||
return super.onTabClick().then(() => {
|
||||
this.collection && this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Conflicts);
|
||||
});
|
||||
}
|
||||
|
||||
public onActivate(): Q.Promise<any> {
|
||||
return super.onActivate().then(() => {
|
||||
if (this._documentsIterator) {
|
||||
return Q.resolve(this._documentsIterator);
|
||||
}
|
||||
|
||||
return this.createIterator().then(
|
||||
(iterator: QueryIterator<ItemDefinition & Resource>) => {
|
||||
this._documentsIterator = iterator;
|
||||
return this.loadNextPage();
|
||||
},
|
||||
error => {
|
||||
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
error: error
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public onRefreshClick(): Q.Promise<any> {
|
||||
return this.refreshDocumentsGrid().then(() => {
|
||||
this.selectedConflictContent("");
|
||||
this.selectedConflictId(null);
|
||||
this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
});
|
||||
}
|
||||
|
||||
public createIterator(): Q.Promise<QueryIterator<ConflictDefinition & Resource>> {
|
||||
// TODO: Conflict Feed does not allow filtering atm
|
||||
const query: string = undefined;
|
||||
let options: any = {};
|
||||
options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey();
|
||||
return this.documentClientUtility.queryConflicts(this.collection.databaseId, this.collection.id(), query, options);
|
||||
}
|
||||
|
||||
public loadNextPage(): Q.Promise<any> {
|
||||
this.isExecuting(true);
|
||||
this.isExecutionError(false);
|
||||
return this._loadNextPageInternal()
|
||||
.then(
|
||||
(conflictIdsResponse: DataModels.ConflictId[]) => {
|
||||
const currentConflicts = this.conflictIds();
|
||||
const currentDocumentsRids = currentConflicts.map(currentConflict => currentConflict.rid);
|
||||
const nextConflictIds = conflictIdsResponse
|
||||
// filter documents already loaded in observable
|
||||
.filter((d: any) => {
|
||||
return currentDocumentsRids.indexOf(d._rid) < 0;
|
||||
})
|
||||
// map raw response to view model
|
||||
.map((rawDocument: any) => {
|
||||
return <ViewModels.ConflictId>new ConflictId(this, rawDocument);
|
||||
});
|
||||
|
||||
const merged = currentConflicts.concat(nextConflictIds);
|
||||
this.conflictIds(merged);
|
||||
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
},
|
||||
error => {
|
||||
this.isExecutionError(true);
|
||||
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
error: error
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
}
|
||||
|
||||
public onLoadMoreKeyInput = (source: any, event: KeyboardEvent): void => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.loadNextPage();
|
||||
document.getElementById(this.documentContentsGridId).focus();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _loadNextPageInternal(): Q.Promise<DataModels.ConflictId[]> {
|
||||
return Q(this._documentsIterator.fetchNext().then(response => response.resources));
|
||||
}
|
||||
|
||||
protected _onEditorContentChange(newContent: string) {
|
||||
try {
|
||||
const parsed: any = JSON.parse(newContent);
|
||||
this.onValidDocumentEdit();
|
||||
} catch (e) {
|
||||
this.onInvalidDocumentEdit();
|
||||
}
|
||||
}
|
||||
|
||||
public initDocumentEditorForCreate(documentId: ViewModels.ConflictId, documentToInsert: any): Q.Promise<any> {
|
||||
if (documentId) {
|
||||
let parsedConflictContent: any = JSON.parse(documentToInsert);
|
||||
const renderedConflictContent: string = this.renderObjectForEditor(parsedConflictContent, null, 4);
|
||||
this.selectedConflictContent.setBaseline(renderedConflictContent);
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
}
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public initDocumentEditorForReplace(
|
||||
documentId: ViewModels.ConflictId,
|
||||
conflictContent: any,
|
||||
currentContent: any
|
||||
): Q.Promise<any> {
|
||||
if (documentId) {
|
||||
currentContent = ConflictsTab.removeSystemProperties(currentContent);
|
||||
const renderedCurrentContent: string = this.renderObjectForEditor(currentContent, null, 4);
|
||||
this.selectedConflictCurrent.setBaseline(renderedCurrentContent);
|
||||
|
||||
let parsedConflictContent: any = JSON.parse(conflictContent);
|
||||
parsedConflictContent = ConflictsTab.removeSystemProperties(parsedConflictContent);
|
||||
|
||||
const renderedConflictContent: string = this.renderObjectForEditor(parsedConflictContent, null, 4);
|
||||
this.selectedConflictContent.setBaseline(renderedConflictContent);
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
}
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public initDocumentEditorForDelete(documentId: ViewModels.ConflictId, documentToDelete: any): Q.Promise<any> {
|
||||
if (documentId) {
|
||||
let parsedDocumentToDelete: any = JSON.parse(documentToDelete);
|
||||
parsedDocumentToDelete = ConflictsTab.removeSystemProperties(parsedDocumentToDelete);
|
||||
const renderedDocumentToDelete: string = this.renderObjectForEditor(parsedDocumentToDelete, null, 4);
|
||||
this.selectedConflictContent.setBaseline(renderedDocumentToDelete);
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
}
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public initDocumentEditorForNoOp(documentId: ViewModels.ConflictId): Q.Promise<any> {
|
||||
this.selectedConflictContent(null);
|
||||
this.selectedConflictCurrent(null);
|
||||
this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
return Q();
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const buttons: ViewModels.NavbarButtonConfig[] = [];
|
||||
const label = this._acceptButtonLabel();
|
||||
if (this.acceptChangesButton.visible()) {
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onAcceptChangesClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.acceptChangesButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.discardButton.visible()) {
|
||||
const label = "Discard";
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onDiscardClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.discardButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.deleteButton.visible()) {
|
||||
const label = "Delete";
|
||||
buttons.push({
|
||||
iconSrc: DeleteIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onDeleteClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.deleteButton.enabled()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
ko.computed(() =>
|
||||
ko.toJSON([
|
||||
this._acceptButtonLabel,
|
||||
this.acceptChangesButton.visible,
|
||||
this.acceptChangesButton.enabled,
|
||||
this.discardButton.visible,
|
||||
this.discardButton.enabled,
|
||||
this.deleteButton.visible,
|
||||
this.deleteButton.enabled
|
||||
])
|
||||
).subscribe(() => this.updateNavbarWithTabsButtons());
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
|
||||
/** Remove system properties from the JSON object.
|
||||
* This includes: _etag, _rid, _self, _attachments, _ts
|
||||
*/
|
||||
public static removeSystemProperties(jsonObject: any): any {
|
||||
if (!jsonObject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
delete jsonObject["_etag"];
|
||||
delete jsonObject["_ts"];
|
||||
delete jsonObject["_rid"];
|
||||
delete jsonObject["_self"];
|
||||
delete jsonObject["_attachments"];
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
private _isIgnoreDirtyEditor = (): boolean => {
|
||||
var msg: string = "Changes will be lost. Do you want to continue?";
|
||||
return window.confirm(msg);
|
||||
};
|
||||
|
||||
private _getPartitionKeyPropertyHeader(): string {
|
||||
return (
|
||||
(this.partitionKey &&
|
||||
this.partitionKey.paths &&
|
||||
this.partitionKey.paths.length > 0 &&
|
||||
this.partitionKey.paths[0]) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
101
src/Explorer/Tabs/DatabaseSettingsTab.html
Normal file
101
src/Explorer/Tabs/DatabaseSettingsTab.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<div
|
||||
class="tab-pane flexContainer"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: tabId
|
||||
},
|
||||
visible: isActive"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div class="warningErrorContainer scaleWarningContainer" data-bind="visible: shouldShowStatusBar">
|
||||
<div>
|
||||
<div class="warningErrorContent" data-bind="visible: shouldShowNotificationStatusPrompt">
|
||||
<span><img src="/info_color.svg" alt="Info"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer" data-bind="html: notificationStatusInfo"></span>
|
||||
</div>
|
||||
<div class="warningErrorContent" data-bind="visible: !shouldShowNotificationStatusPrompt()">
|
||||
<span><img src="/warning.svg" alt="Warning"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer" data-bind="html: warningMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabForm scaleSettingScrollable">
|
||||
<div class="scaleDivison" aria-label="Scale" aria-controls="scaleRegion">
|
||||
<span class="scaleSettingTitle">Scale</span>
|
||||
</div>
|
||||
<div class="ssTextAllignment" id="scaleRegion">
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && !hasAutoPilotV2FeatureFlag() -->
|
||||
<throughput-input-autopilot-v3
|
||||
params="{
|
||||
testId: testId,
|
||||
class: 'scaleForm dirty',
|
||||
value: throughput,
|
||||
minimum: minRUs,
|
||||
maximum: maxRUThroughputInputLimit,
|
||||
canExceedMaximumValue: canThroughputExceedMaximumValue,
|
||||
step: throughputIncreaseFactor,
|
||||
label: throughputTitle,
|
||||
ariaLabel: throughputAriaLabel,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost,
|
||||
throughputAutoPilotRadioId: throughputAutoPilotRadioId,
|
||||
throughputProvisionedRadioId: throughputProvisionedRadioId,
|
||||
throughputModeRadioName: throughputModeRadioName,
|
||||
showAutoPilot: userCanChangeProvisioningTypes,
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
maxAutoPilotThroughputSet: autoPilotThroughput,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue,
|
||||
overrideWithAutoPilotSettings: overrideWithAutoPilotSettings,
|
||||
overrideWithProvisionedThroughputSettings: overrideWithProvisionedThroughputSettings
|
||||
}"
|
||||
>
|
||||
</throughput-input-autopilot-v3>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && hasAutoPilotV2FeatureFlag() -->
|
||||
<throughput-input
|
||||
params="{
|
||||
testId: testId,
|
||||
class: 'scaleForm dirty',
|
||||
value: throughput,
|
||||
minimum: minRUs,
|
||||
maximum: maxRUThroughputInputLimit,
|
||||
canExceedMaximumValue: canThroughputExceedMaximumValue,
|
||||
step: throughputIncreaseFactor,
|
||||
label: throughputTitle,
|
||||
ariaLabel: throughputAriaLabel,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost,
|
||||
throughputAutoPilotRadioId: throughputAutoPilotRadioId,
|
||||
throughputProvisionedRadioId: throughputProvisionedRadioId,
|
||||
throughputModeRadioName: throughputModeRadioName,
|
||||
showAutoPilot: userCanChangeProvisioningTypes,
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
autoPilotTiersList: autoPilotTiersList,
|
||||
selectedAutoPilotTier: selectedAutoPilotTier,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue
|
||||
}"
|
||||
>
|
||||
</throughput-input>
|
||||
<!-- /ko -->
|
||||
|
||||
<div class="estimatedCost" data-bind="visible: costsVisible">
|
||||
<p data-bind="visible: minRUAnotationVisible">
|
||||
<span>Learn more about minimum throughput </span>
|
||||
<a href="https://docs.microsoft.com/azure/cosmos-db/set-throughput" target="_blank">here.</a>
|
||||
</p>
|
||||
<p data-bind="visible: canRequestSupport">
|
||||
<!-- TODO: Replace link with call to the Azure Support blade -->
|
||||
<a href="https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20More%20Throughput%20Request"
|
||||
>Contact support</a
|
||||
>
|
||||
for more than <span data-bind="text: maxRUsText"></span> RU/s
|
||||
</p>
|
||||
<p data-bind="visible: shouldDisplayPortalUsePrompt">
|
||||
Use Data Explorer from Azure Portal to request more than <span data-bind="text: maxRUsText"></span> RU/s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
719
src/Explorer/Tabs/DatabaseSettingsTab.ts
Normal file
719
src/Explorer/Tabs/DatabaseSettingsTab.ts
Normal file
@@ -0,0 +1,719 @@
|
||||
import * as AddCollectionUtility from "../../Shared/AddCollectionUtility";
|
||||
import * as AutoPilotUtils from "../../Utils/AutoPilotUtils";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import * as ko from "knockout";
|
||||
import * as PricingUtils from "../../Utils/PricingUtils";
|
||||
import * as SharedConstants from "../../Shared/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import DiscardIcon from "../../../images/discard.svg";
|
||||
import editable from "../../Common/EditableUtility";
|
||||
import Q from "q";
|
||||
import SaveIcon from "../../../images/save-cosmos.svg";
|
||||
import TabsBase from "./TabsBase";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { CosmosClient } from "../../Common/CosmosClient";
|
||||
import { PlatformType } from "../../PlatformType";
|
||||
import { RequestOptions } from "@azure/cosmos/dist-esm";
|
||||
|
||||
const updateThroughputBeyondLimitWarningMessage: string = `
|
||||
You are about to request an increase in throughput beyond the pre-allocated capacity.
|
||||
The service will scale out and increase throughput for the selected database.
|
||||
This operation will take 1-3 business days to complete. You can track the status of this request in Notifications.`;
|
||||
|
||||
const updateThroughputDelayedApplyWarningMessage: string = `
|
||||
You are about to request an increase in throughput beyond the pre-allocated capacity.
|
||||
This operation will take some time to complete.`;
|
||||
|
||||
const currentThroughput: (isAutoscale: boolean, throughput: number) => string = (isAutoscale, throughput) =>
|
||||
isAutoscale
|
||||
? `Current autoscale throughput: ${Math.round(throughput / 10)} - ${throughput} RU/s`
|
||||
: `Current manual throughput: ${throughput} RU/s`;
|
||||
|
||||
const throughputApplyDelayedMessage = (isAutoscale: boolean, throughput: number, databaseName: string) =>
|
||||
`The request to increase the throughput has successfully been submitted.
|
||||
This operation will take 1-3 business days to complete. View the latest status in Notifications.<br />
|
||||
Database: ${databaseName}, ${currentThroughput(isAutoscale, throughput)}`;
|
||||
|
||||
const throughputApplyShortDelayMessage = (isAutoscale: boolean, throughput: number, databaseName: string) =>
|
||||
`A request to increase the throughput is currently in progress.
|
||||
This operation will take some time to complete.<br />
|
||||
Database: ${databaseName}, ${currentThroughput(isAutoscale, throughput)}`;
|
||||
|
||||
const throughputApplyLongDelayMessage = (isAutoscale: boolean, throughput: number, databaseName: string) =>
|
||||
`A request to increase the throughput is currently in progress.
|
||||
This operation will take 1-3 business days to complete. View the latest status in Notifications.<br />
|
||||
Database: ${databaseName}, ${currentThroughput(isAutoscale, throughput)}`;
|
||||
|
||||
export default class DatabaseSettingsTab extends TabsBase
|
||||
implements ViewModels.DatabaseSettingsTab, ViewModels.WaitsForTemplate {
|
||||
// editables
|
||||
public isAutoPilotSelected: ViewModels.Editable<boolean>;
|
||||
public throughput: ViewModels.Editable<number>;
|
||||
public selectedAutoPilotTier: ViewModels.Editable<DataModels.AutopilotTier>;
|
||||
public autoPilotThroughput: ViewModels.Editable<number>;
|
||||
public throughputIncreaseFactor: number = Constants.ClientDefaults.databaseThroughputIncreaseFactor;
|
||||
|
||||
public saveSettingsButton: ViewModels.Button;
|
||||
public discardSettingsChangesButton: ViewModels.Button;
|
||||
|
||||
public canRequestSupport: ko.PureComputed<boolean>;
|
||||
public canThroughputExceedMaximumValue: ko.Computed<boolean>;
|
||||
public costsVisible: ko.Computed<boolean>;
|
||||
public displayedError: ko.Observable<string>;
|
||||
public isTemplateReady: ko.Observable<boolean>;
|
||||
public minRUAnotationVisible: ko.Computed<boolean>;
|
||||
public minRUs: ko.Computed<number>;
|
||||
public maxRUs: ko.Computed<number>;
|
||||
public maxRUsText: ko.PureComputed<string>;
|
||||
public maxRUThroughputInputLimit: ko.Computed<number>;
|
||||
public notificationStatusInfo: ko.Observable<string>;
|
||||
public pendingNotification: ko.Observable<DataModels.Notification>;
|
||||
public requestUnitsUsageCost: ko.PureComputed<string>;
|
||||
public autoscaleCost: ko.PureComputed<string>;
|
||||
public shouldShowNotificationStatusPrompt: ko.Computed<boolean>;
|
||||
public shouldDisplayPortalUsePrompt: ko.Computed<boolean>;
|
||||
public shouldShowStatusBar: ko.Computed<boolean>;
|
||||
public throughputTitle: ko.PureComputed<string>;
|
||||
public throughputAriaLabel: ko.PureComputed<string>;
|
||||
public userCanChangeProvisioningTypes: ko.Observable<boolean>;
|
||||
public autoPilotTiersList: ko.ObservableArray<ViewModels.DropdownOption<DataModels.AutopilotTier>>;
|
||||
public autoPilotUsageCost: ko.PureComputed<string>;
|
||||
public warningMessage: ko.Computed<string>;
|
||||
public canExceedMaximumValue: ko.PureComputed<boolean>;
|
||||
public hasAutoPilotV2FeatureFlag: ko.PureComputed<boolean>;
|
||||
public overrideWithAutoPilotSettings: ko.Computed<boolean>;
|
||||
public overrideWithProvisionedThroughputSettings: ko.Computed<boolean>;
|
||||
public testId: string;
|
||||
public throughputAutoPilotRadioId: string;
|
||||
public throughputProvisionedRadioId: string;
|
||||
public throughputModeRadioName: string;
|
||||
|
||||
private _hasProvisioningTypeChanged: ko.Computed<boolean>;
|
||||
private _wasAutopilotOriginallySet: ko.Observable<boolean>;
|
||||
private _offerReplacePending: ko.Computed<boolean>;
|
||||
private container: ViewModels.Explorer;
|
||||
|
||||
constructor(options: ViewModels.TabOptions) {
|
||||
super(options);
|
||||
|
||||
this.container = options.node && (options.node as ViewModels.Database).container;
|
||||
this.hasAutoPilotV2FeatureFlag = ko.pureComputed(() => this.container.hasAutoPilotV2FeatureFlag());
|
||||
this.selectedAutoPilotTier = editable.observable<DataModels.AutopilotTier>();
|
||||
this.autoPilotTiersList = ko.observableArray<ViewModels.DropdownOption<DataModels.AutopilotTier>>();
|
||||
this.canExceedMaximumValue = ko.pureComputed(() => this.container.canExceedMaximumValue());
|
||||
|
||||
// html element ids
|
||||
this.testId = `scaleSettingThroughputValue${this.tabId}`;
|
||||
this.throughputAutoPilotRadioId = `editContainerThroughput-autoPilotRadio${this.tabId}`;
|
||||
this.throughputProvisionedRadioId = `editContainerThroughput-manualRadio${this.tabId}`;
|
||||
this.throughputModeRadioName = `throughputModeRadio${this.tabId}`;
|
||||
|
||||
this.throughput = editable.observable<number>();
|
||||
this._wasAutopilotOriginallySet = ko.observable(false);
|
||||
this.isAutoPilotSelected = editable.observable(false);
|
||||
this.autoPilotThroughput = editable.observable<number>();
|
||||
const offer = this.database && this.database.offer && this.database.offer();
|
||||
const offerAutopilotSettings = offer && offer.content && offer.content.offerAutopilotSettings;
|
||||
this.userCanChangeProvisioningTypes = ko.observable(!!offerAutopilotSettings || !this.hasAutoPilotV2FeatureFlag());
|
||||
if (!this.hasAutoPilotV2FeatureFlag()) {
|
||||
if (offerAutopilotSettings && offerAutopilotSettings.maxThroughput) {
|
||||
if (AutoPilotUtils.isValidAutoPilotThroughput(offerAutopilotSettings.maxThroughput)) {
|
||||
this._wasAutopilotOriginallySet(true);
|
||||
this.isAutoPilotSelected(true);
|
||||
this.autoPilotThroughput(offerAutopilotSettings.maxThroughput);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (offerAutopilotSettings && offerAutopilotSettings.tier) {
|
||||
if (AutoPilotUtils.isValidAutoPilotTier(offerAutopilotSettings.tier)) {
|
||||
this._wasAutopilotOriginallySet(true);
|
||||
this.isAutoPilotSelected(true);
|
||||
this.selectedAutoPilotTier(offerAutopilotSettings.tier);
|
||||
this.autoPilotTiersList(AutoPilotUtils.getAvailableAutoPilotTiersOptions(offerAutopilotSettings.tier));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._hasProvisioningTypeChanged = ko.pureComputed<boolean>(() => {
|
||||
if (!this.userCanChangeProvisioningTypes()) {
|
||||
return false;
|
||||
}
|
||||
if (this._wasAutopilotOriginallySet() !== this.isAutoPilotSelected()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.autoPilotUsageCost = ko.pureComputed<string>(() => {
|
||||
const autoPilot = !this.hasAutoPilotV2FeatureFlag() ? this.autoPilotThroughput() : this.selectedAutoPilotTier();
|
||||
if (!autoPilot) {
|
||||
return "";
|
||||
}
|
||||
return !this.hasAutoPilotV2FeatureFlag()
|
||||
? PricingUtils.getAutoPilotV3SpendHtml(autoPilot, true /* isDatabaseThroughput */)
|
||||
: PricingUtils.getAutoPilotV2SpendHtml(autoPilot, true /* isDatabaseThroughput */);
|
||||
});
|
||||
|
||||
this.requestUnitsUsageCost = ko.pureComputed(() => {
|
||||
const account = this.container.databaseAccount();
|
||||
if (!account) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const serverId = this.container.serverId();
|
||||
const regions =
|
||||
(account &&
|
||||
account.properties &&
|
||||
account.properties.readLocations &&
|
||||
account.properties.readLocations.length) ||
|
||||
1;
|
||||
const multimaster = (account && account.properties && account.properties.enableMultipleWriteLocations) || false;
|
||||
|
||||
let estimatedSpend: string;
|
||||
if (!this.isAutoPilotSelected()) {
|
||||
estimatedSpend = PricingUtils.getEstimatedSpendHtml(
|
||||
// if migrating from autoscale to manual, we use the autoscale RUs value as that is what will be set...
|
||||
this.overrideWithAutoPilotSettings() ? this.autoPilotThroughput() : this.throughput(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/
|
||||
);
|
||||
} else {
|
||||
estimatedSpend = PricingUtils.getEstimatedAutoscaleSpendHtml(
|
||||
this.autoPilotThroughput(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster
|
||||
);
|
||||
}
|
||||
return estimatedSpend;
|
||||
});
|
||||
|
||||
this.costsVisible = ko.computed(() => {
|
||||
return !this.container.isEmulator;
|
||||
});
|
||||
|
||||
this.shouldDisplayPortalUsePrompt = ko.pureComputed<boolean>(
|
||||
() => this.container.getPlatformType() === PlatformType.Hosted
|
||||
);
|
||||
this.canThroughputExceedMaximumValue = ko.pureComputed<boolean>(
|
||||
() => this.container.getPlatformType() === PlatformType.Portal && !this.container.isRunningOnNationalCloud()
|
||||
);
|
||||
this.canRequestSupport = ko.pureComputed(() => {
|
||||
if (
|
||||
!!this.container.isEmulator ||
|
||||
this.container.getPlatformType() === PlatformType.Hosted ||
|
||||
this.canThroughputExceedMaximumValue()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
this.overrideWithAutoPilotSettings = ko.pureComputed(() => {
|
||||
if (this.hasAutoPilotV2FeatureFlag()) {
|
||||
return false;
|
||||
}
|
||||
return this._hasProvisioningTypeChanged() && this._wasAutopilotOriginallySet();
|
||||
});
|
||||
|
||||
this.overrideWithProvisionedThroughputSettings = ko.pureComputed(() => {
|
||||
if (this.hasAutoPilotV2FeatureFlag()) {
|
||||
return false;
|
||||
}
|
||||
return this._hasProvisioningTypeChanged() && !this._wasAutopilotOriginallySet();
|
||||
});
|
||||
|
||||
this.minRUs = ko.computed<number>(() => {
|
||||
const offerContent =
|
||||
this.database && this.database.offer && this.database.offer() && this.database.offer().content;
|
||||
|
||||
// TODO: backend is returning 1,000,000 as min throughput which seems wrong
|
||||
// Setting to min throughput to not block and let the backend pass or fail
|
||||
if (offerContent && offerContent.offerAutopilotSettings) {
|
||||
return 400;
|
||||
}
|
||||
|
||||
const collectionThroughputInfo: DataModels.OfferThroughputInfo =
|
||||
offerContent && offerContent.collectionThroughputInfo;
|
||||
|
||||
if (collectionThroughputInfo && !!collectionThroughputInfo.minimumRUForCollection) {
|
||||
return collectionThroughputInfo.minimumRUForCollection;
|
||||
}
|
||||
|
||||
const flight = this.container.flight();
|
||||
const subscriptionType = this.container.subscriptionType();
|
||||
const throughputDefaults = AddCollectionUtility.Utilities.getDefaultThroughput(flight, subscriptionType);
|
||||
|
||||
return throughputDefaults.unlimitedmin;
|
||||
});
|
||||
|
||||
this.minRUAnotationVisible = ko.computed<boolean>(() => {
|
||||
return PricingUtils.isLargerThanDefaultMinRU(this.minRUs());
|
||||
});
|
||||
|
||||
this.maxRUs = ko.computed<number>(() => {
|
||||
const collectionThroughputInfo: DataModels.OfferThroughputInfo =
|
||||
this.database &&
|
||||
this.database.offer &&
|
||||
this.database.offer() &&
|
||||
this.database.offer().content &&
|
||||
this.database.offer().content.collectionThroughputInfo;
|
||||
const numPartitions = collectionThroughputInfo && collectionThroughputInfo.numPhysicalPartitions;
|
||||
if (!!numPartitions) {
|
||||
return SharedConstants.CollectionCreation.MaxRUPerPartition * numPartitions;
|
||||
}
|
||||
|
||||
const flight = this.container.flight();
|
||||
const subscriptionType = this.container.subscriptionType();
|
||||
const throughputDefaults = AddCollectionUtility.Utilities.getDefaultThroughput(flight, subscriptionType);
|
||||
|
||||
return throughputDefaults.unlimitedmax;
|
||||
});
|
||||
|
||||
this.maxRUThroughputInputLimit = ko.pureComputed<number>(() => {
|
||||
if (this.container && this.container.getPlatformType() === PlatformType.Hosted) {
|
||||
return SharedConstants.CollectionCreation.DefaultCollectionRUs1Million;
|
||||
}
|
||||
|
||||
return this.maxRUs();
|
||||
});
|
||||
|
||||
this.maxRUsText = ko.pureComputed(() => {
|
||||
return SharedConstants.CollectionCreation.DefaultCollectionRUs1Million.toLocaleString();
|
||||
});
|
||||
|
||||
this.throughputTitle = ko.pureComputed<string>(() => {
|
||||
if (this.isAutoPilotSelected()) {
|
||||
return AutoPilotUtils.getAutoPilotHeaderText(this.hasAutoPilotV2FeatureFlag());
|
||||
}
|
||||
|
||||
return `Throughput (${this.minRUs().toLocaleString()} - unlimited RU/s)`;
|
||||
});
|
||||
|
||||
this.throughputAriaLabel = ko.pureComputed<string>(() => {
|
||||
return this.throughputTitle() + this.requestUnitsUsageCost();
|
||||
});
|
||||
this.pendingNotification = ko.observable<DataModels.Notification>();
|
||||
this._offerReplacePending = ko.pureComputed<boolean>(() => {
|
||||
const offer = this.database && this.database.offer && this.database.offer();
|
||||
return (
|
||||
offer &&
|
||||
offer.hasOwnProperty("headers") &&
|
||||
!!(offer as DataModels.OfferWithHeaders).headers[Constants.HttpHeaders.offerReplacePending]
|
||||
);
|
||||
});
|
||||
this.notificationStatusInfo = ko.observable<string>("");
|
||||
this.shouldShowNotificationStatusPrompt = ko.computed<boolean>(() => this.notificationStatusInfo().length > 0);
|
||||
this.warningMessage = ko.computed<string>(() => {
|
||||
const offer = this.database && this.database.offer && this.database.offer();
|
||||
|
||||
if (!this.hasAutoPilotV2FeatureFlag() && this.overrideWithProvisionedThroughputSettings()) {
|
||||
return AutoPilotUtils.manualToAutoscaleDisclaimer;
|
||||
}
|
||||
|
||||
if (
|
||||
offer &&
|
||||
offer.hasOwnProperty("headers") &&
|
||||
!!(offer as DataModels.OfferWithHeaders).headers[Constants.HttpHeaders.offerReplacePending]
|
||||
) {
|
||||
const throughput = offer.content.offerAutopilotSettings
|
||||
? !this.hasAutoPilotV2FeatureFlag()
|
||||
? offer.content.offerAutopilotSettings.maxThroughput
|
||||
: offer.content.offerAutopilotSettings.maximumTierThroughput
|
||||
: offer.content.offerThroughput;
|
||||
|
||||
return throughputApplyShortDelayMessage(this.isAutoPilotSelected(), throughput, this.database.id());
|
||||
}
|
||||
|
||||
if (
|
||||
this.maxRUs() <= SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
|
||||
this.throughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
|
||||
this.canThroughputExceedMaximumValue()
|
||||
) {
|
||||
return updateThroughputBeyondLimitWarningMessage;
|
||||
}
|
||||
|
||||
if (this.throughput() > this.maxRUs()) {
|
||||
return updateThroughputDelayedApplyWarningMessage;
|
||||
}
|
||||
|
||||
if (this.pendingNotification()) {
|
||||
const matches: string[] = this.pendingNotification().description.match("Throughput update for (.*) RU/s");
|
||||
const throughput: number = matches.length > 1 && Number(matches[1]);
|
||||
|
||||
if (throughput) {
|
||||
return throughputApplyLongDelayMessage(this.isAutoPilotSelected(), throughput, this.database.id());
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
this.warningMessage.subscribe((warning: string) => {
|
||||
if (warning.length > 0) {
|
||||
this.notificationStatusInfo("");
|
||||
}
|
||||
});
|
||||
|
||||
this.shouldShowStatusBar = ko.computed<boolean>(
|
||||
() => this.shouldShowNotificationStatusPrompt() || (this.warningMessage && this.warningMessage().length > 0)
|
||||
);
|
||||
|
||||
this.displayedError = ko.observable<string>("");
|
||||
|
||||
this._setBaseline();
|
||||
|
||||
this.saveSettingsButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
if (this._hasProvisioningTypeChanged()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._offerReplacePending && this._offerReplacePending()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAutoPilot = this.isAutoPilotSelected();
|
||||
const isManual = !this.isAutoPilotSelected();
|
||||
if (isAutoPilot) {
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() &&
|
||||
!AutoPilotUtils.isValidAutoPilotThroughput(this.autoPilotThroughput())) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() && !AutoPilotUtils.isValidAutoPilotTier(this.selectedAutoPilotTier()))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.isAutoPilotSelected.editableIsDirty()) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() && this.autoPilotThroughput.editableIsDirty()) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() && this.selectedAutoPilotTier.editableIsDirty())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (isManual) {
|
||||
if (!this.throughput()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.throughput() < this.minRUs()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.canThroughputExceedMaximumValue() &&
|
||||
this.throughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.throughput.editableIsDirty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected.editableIsDirty()) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() && this.selectedAutoPilotTier.editableIsDirty())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.discardSettingsChangesButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
if (this.throughput.editableIsDirty()) {
|
||||
return true;
|
||||
}
|
||||
if (this.isAutoPilotSelected.editableIsDirty()) {
|
||||
return true;
|
||||
}
|
||||
if (this.autoPilotThroughput.editableIsDirty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.isTemplateReady = ko.observable<boolean>(false);
|
||||
|
||||
this._buildCommandBarOptions();
|
||||
}
|
||||
|
||||
public onSaveClick = (): Q.Promise<any> => {
|
||||
let promises: Q.Promise<void>[] = [];
|
||||
this.isExecutionError(false);
|
||||
|
||||
this.isExecuting(true);
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.UpdateSettings, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
|
||||
const headerOptions: RequestOptions = { initialHeaders: {} };
|
||||
|
||||
if (this.isAutoPilotSelected()) {
|
||||
const offer = this.database.offer();
|
||||
let offerAutopilotSettings: any = {};
|
||||
if (!this.hasAutoPilotV2FeatureFlag()) {
|
||||
offerAutopilotSettings.maxThroughput = this.autoPilotThroughput();
|
||||
} else {
|
||||
offerAutopilotSettings.tier = this.selectedAutoPilotTier();
|
||||
}
|
||||
const newOffer: DataModels.Offer = {
|
||||
content: {
|
||||
offerThroughput: undefined,
|
||||
offerIsRUPerMinuteThroughputEnabled: false,
|
||||
offerAutopilotSettings
|
||||
},
|
||||
_etag: undefined,
|
||||
_ts: undefined,
|
||||
_rid: offer._rid,
|
||||
_self: offer._self,
|
||||
id: offer.id,
|
||||
offerResourceId: offer.offerResourceId,
|
||||
offerVersion: offer.offerVersion,
|
||||
offerType: offer.offerType,
|
||||
resource: offer.resource
|
||||
};
|
||||
|
||||
// user has changed from provisioned --> autoscale
|
||||
if (!this.hasAutoPilotV2FeatureFlag() && this._hasProvisioningTypeChanged()) {
|
||||
headerOptions.initialHeaders[Constants.HttpHeaders.migrateOfferToAutopilot] = "true";
|
||||
delete newOffer.content.offerAutopilotSettings;
|
||||
}
|
||||
|
||||
const updateOfferPromise = this.container.documentClientUtility
|
||||
.updateOffer(this.database.offer(), newOffer, headerOptions)
|
||||
.then((updatedOffer: DataModels.Offer) => {
|
||||
this.database.offer(updatedOffer);
|
||||
this.database.offer.valueHasMutated();
|
||||
this._wasAutopilotOriginallySet(this.isAutoPilotSelected());
|
||||
});
|
||||
promises.push(updateOfferPromise);
|
||||
} else {
|
||||
if (this.throughput.editableIsDirty() || this.isAutoPilotSelected.editableIsDirty()) {
|
||||
const offer = this.database.offer();
|
||||
const originalThroughputValue = this.throughput.getEditableOriginalValue();
|
||||
const newThroughput = this.throughput();
|
||||
|
||||
if (
|
||||
this.canThroughputExceedMaximumValue() &&
|
||||
this.maxRUs() <= SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
|
||||
this.throughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million
|
||||
) {
|
||||
const requestPayload: DataModels.UpdateOfferThroughputRequest = {
|
||||
subscriptionId: CosmosClient.subscriptionId(),
|
||||
databaseAccountName: CosmosClient.databaseAccount().name,
|
||||
resourceGroup: CosmosClient.resourceGroup(),
|
||||
databaseName: this.database.id(),
|
||||
collectionName: undefined,
|
||||
throughput: newThroughput,
|
||||
offerIsRUPerMinuteThroughputEnabled: false
|
||||
};
|
||||
const updateOfferBeyondLimitPromise: Q.Promise<void> = this.documentClientUtility
|
||||
.updateOfferThroughputBeyondLimit(requestPayload)
|
||||
.then(
|
||||
() => {
|
||||
this.database.offer().content.offerThroughput = originalThroughputValue;
|
||||
this.throughput(originalThroughputValue);
|
||||
this.notificationStatusInfo(
|
||||
throughputApplyDelayedMessage(this.isAutoPilotSelected(), newThroughput, this.database.id())
|
||||
);
|
||||
this.throughput.valueHasMutated(); // force component re-render
|
||||
},
|
||||
(error: any) => {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateSettings,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
databaseName: this.database && this.database.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
error: error
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
);
|
||||
promises.push(updateOfferBeyondLimitPromise);
|
||||
} else {
|
||||
const newOffer: DataModels.Offer = {
|
||||
content: {
|
||||
offerThroughput: newThroughput,
|
||||
offerIsRUPerMinuteThroughputEnabled: false
|
||||
},
|
||||
_etag: undefined,
|
||||
_ts: undefined,
|
||||
_rid: offer._rid,
|
||||
_self: offer._self,
|
||||
id: offer.id,
|
||||
offerResourceId: offer.offerResourceId,
|
||||
offerVersion: offer.offerVersion,
|
||||
offerType: offer.offerType,
|
||||
resource: offer.resource
|
||||
};
|
||||
|
||||
// user has changed from autoscale --> provisioned
|
||||
if (!this.hasAutoPilotV2FeatureFlag() && this._hasProvisioningTypeChanged()) {
|
||||
headerOptions.initialHeaders[Constants.HttpHeaders.migrateOfferToManualThroughput] = "true";
|
||||
newOffer.content.offerAutopilotSettings = { maxThroughput: 0 };
|
||||
}
|
||||
|
||||
const updateOfferPromise = this.container.documentClientUtility
|
||||
.updateOffer(this.database.offer(), newOffer, headerOptions)
|
||||
.then((updatedOffer: DataModels.Offer) => {
|
||||
this._wasAutopilotOriginallySet(this.isAutoPilotSelected());
|
||||
this.database.offer(updatedOffer);
|
||||
this.database.offer.valueHasMutated();
|
||||
});
|
||||
|
||||
promises.push(updateOfferPromise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (promises.length === 0) {
|
||||
this.isExecuting(false);
|
||||
}
|
||||
|
||||
return Q.all(promises)
|
||||
.then(
|
||||
() => {
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this._setBaseline();
|
||||
this.database.readSettings();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateSettings,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
(reason: any) => {
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this.isExecutionError(true);
|
||||
console.error(reason);
|
||||
this.displayedError(ErrorParserUtility.parse(reason)[0].message);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateSettings,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public onRevertClick = (): Q.Promise<any> => {
|
||||
this.throughput.setBaseline(this.throughput.getEditableOriginalValue());
|
||||
this.isAutoPilotSelected.setBaseline(this.isAutoPilotSelected.getEditableOriginalValue());
|
||||
if (!this.hasAutoPilotV2FeatureFlag()) {
|
||||
this.autoPilotThroughput.setBaseline(this.autoPilotThroughput.getEditableOriginalValue());
|
||||
} else {
|
||||
this.selectedAutoPilotTier.setBaseline(this.selectedAutoPilotTier.getEditableOriginalValue());
|
||||
}
|
||||
|
||||
return Q();
|
||||
};
|
||||
|
||||
public onActivate(): Q.Promise<any> {
|
||||
return super.onActivate().then(() => {
|
||||
this.database.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings);
|
||||
});
|
||||
}
|
||||
|
||||
private _setBaseline() {
|
||||
const offer = this.database && this.database.offer && this.database.offer();
|
||||
const offerThroughput = offer.content && offer.content.offerThroughput;
|
||||
const offerAutopilotSettings = offer && offer.content && offer.content.offerAutopilotSettings;
|
||||
|
||||
this.throughput.setBaseline(offerThroughput);
|
||||
this.userCanChangeProvisioningTypes(!!offerAutopilotSettings || !this.hasAutoPilotV2FeatureFlag());
|
||||
|
||||
if (this.hasAutoPilotV2FeatureFlag()) {
|
||||
const selectedAutoPilotTier = offerAutopilotSettings && offerAutopilotSettings.tier;
|
||||
this.isAutoPilotSelected.setBaseline(AutoPilotUtils.isValidAutoPilotTier(selectedAutoPilotTier));
|
||||
this.selectedAutoPilotTier.setBaseline(selectedAutoPilotTier);
|
||||
} else {
|
||||
const maxThroughputForAutoPilot = offerAutopilotSettings && offerAutopilotSettings.maxThroughput;
|
||||
this.isAutoPilotSelected.setBaseline(AutoPilotUtils.isValidAutoPilotThroughput(maxThroughputForAutoPilot));
|
||||
this.autoPilotThroughput.setBaseline(maxThroughputForAutoPilot || AutoPilotUtils.minAutoPilotThroughput);
|
||||
}
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const buttons: ViewModels.NavbarButtonConfig[] = [];
|
||||
const label = "Save";
|
||||
if (this.saveSettingsButton.visible()) {
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onSaveClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveSettingsButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.discardSettingsChangesButton.visible()) {
|
||||
const label = "Discard";
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onRevertClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.discardSettingsChangesButton.enabled()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private _buildCommandBarOptions(): void {
|
||||
ko.computed(() =>
|
||||
ko.toJSON([
|
||||
this.saveSettingsButton.visible,
|
||||
this.saveSettingsButton.enabled,
|
||||
this.discardSettingsChangesButton.visible,
|
||||
this.discardSettingsChangesButton.enabled
|
||||
])
|
||||
).subscribe(() => this.updateNavbarWithTabsButtons());
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
}
|
||||
226
src/Explorer/Tabs/DocumentsTab.html
Normal file
226
src/Explorer/Tabs/DocumentsTab.html
Normal file
@@ -0,0 +1,226 @@
|
||||
<div
|
||||
class="tab-pane active tabdocuments flexContainer"
|
||||
data-bind="
|
||||
setTemplateReady: true,
|
||||
attr:{
|
||||
id: tabId
|
||||
},
|
||||
visible: isActive"
|
||||
role="tabpanel"
|
||||
>
|
||||
<!-- ko if: false -->
|
||||
<!-- Messagebox Ok Cancel- Start -->
|
||||
<div class="messagebox-background">
|
||||
<div class="messagebox">
|
||||
<h2 class="messagebox-title">Title</h2>
|
||||
<div class="messagebox-text" tabindex="0">Text</div>
|
||||
<div class="messagebox-buttons">
|
||||
<div class="messagebox-buttons-container">
|
||||
<button value="ok" class="messagebox-button-primary">Ok</button>
|
||||
<button value="cancel" class="messagebox-button-default">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Messagebox OK Cancel - End -->
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- Filter - Start -->
|
||||
<div class="filterdivs" data-bind="visible: isFilterCreated">
|
||||
<!-- Read-only Filter - Start -->
|
||||
<div class="filterDocCollapsed" data-bind="visible: !isFilterExpanded() && !isPreferredApiMongoDB">
|
||||
<span class="selectQuery">SELECT * FROM c</span>
|
||||
<span class="appliedQuery" data-bind="text: appliedFilter"></span>
|
||||
<button class="filterbtnstyle queryButton" data-bind="click: onShowFilterClick">
|
||||
Edit Filter
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="filterDocCollapsed"
|
||||
data-bind="
|
||||
visible: !isFilterExpanded() && isPreferredApiMongoDB"
|
||||
>
|
||||
<span
|
||||
class="selectQuery"
|
||||
data-bind="
|
||||
visible: appliedFilter().length > 0"
|
||||
>Filter :
|
||||
</span>
|
||||
<span
|
||||
class="noFilterApplied"
|
||||
data-bind="
|
||||
visible: !appliedFilter().length > 0"
|
||||
>No filter applied</span
|
||||
>
|
||||
<span class="appliedQuery" data-bind="text: appliedFilter"></span>
|
||||
<button
|
||||
class="filterbtnstyle queryButton"
|
||||
data-bind="
|
||||
click: onShowFilterClick"
|
||||
>
|
||||
Edit Filter
|
||||
</button>
|
||||
</div>
|
||||
<!-- Read-only Filter - End -->
|
||||
|
||||
<!-- Editable Filter - start -->
|
||||
<div
|
||||
class="filterDocExpanded"
|
||||
data-bind="
|
||||
visible: isFilterExpanded"
|
||||
>
|
||||
<div>
|
||||
<div class="editFilterContainer">
|
||||
<span class="filterspan" data-bind="visible: !isPreferredApiMongoDB">
|
||||
SELECT * FROM c
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
list="filtersList"
|
||||
class="querydropdown"
|
||||
title="Type a query predicate or choose one from the list."
|
||||
data-bind="
|
||||
attr:{
|
||||
placeholder:isPreferredApiMongoDB?'Type a query predicate (e.g., {´a´:´foo´}), or choose one from the drop down list, or leave empty to query all documents.':'Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents.'
|
||||
},
|
||||
css: { placeholderVisible: filterContent().length === 0 },
|
||||
textInput: filterContent"
|
||||
/>
|
||||
|
||||
<datalist
|
||||
id="filtersList"
|
||||
data-bind="
|
||||
foreach: lastFilterContents"
|
||||
>
|
||||
<option
|
||||
data-bind="
|
||||
value: $data"
|
||||
>
|
||||
</option>
|
||||
</datalist>
|
||||
|
||||
<span class="filterbuttonpad">
|
||||
<button
|
||||
class="filterbtnstyle queryButton"
|
||||
data-bind="
|
||||
click: onApplyFilterClick,
|
||||
enable: applyFilterButton.enabled"
|
||||
aria-label="Apply filter"
|
||||
tabindex="0"
|
||||
>
|
||||
Apply Filter
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
class="filterclose"
|
||||
role="button"
|
||||
aria-label="close filter"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: onHideFilterClick, event: { keydown: onCloseButtonKeyDown }"
|
||||
>
|
||||
<img src="/close-black.svg" style="height: 14px; width: 14px;" alt="Hide filter" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Editable Filter - End -->
|
||||
</div>
|
||||
<!-- Filter - End -->
|
||||
|
||||
<!-- Ids and Editor - Start -->
|
||||
<div class="documentsTabGridAndEditor">
|
||||
<div class="documentsContainerWithSplitter" , data-bind="attr: { id: documentContentsContainerId }">
|
||||
<div class="flexContainer">
|
||||
<!-- Document Ids - Start -->
|
||||
<div
|
||||
class="documentsGridHeaderContainer tabdocuments scrollable"
|
||||
data-bind="
|
||||
attr: {
|
||||
id: documentContentsGridId,
|
||||
tabindex: documentIds().length <= 0 ? -1 : 0
|
||||
},
|
||||
style: { height: dataContentsGridScrollHeight },
|
||||
event: { keydown: accessibleDocumentList.onKeyDown }"
|
||||
>
|
||||
<table id="tabsTable" class="table table-hover can-select dataTable">
|
||||
<thead id="theadcontent">
|
||||
<tr>
|
||||
<th class="documentsGridHeader" data-bind="text: idHeader" tabindex="0"></th>
|
||||
<!-- ko if: showPartitionKey -->
|
||||
<th
|
||||
class="documentsGridHeader documentsGridPartition evenlySpacedHeader"
|
||||
data-bind="
|
||||
attr: {
|
||||
title: partitionKeyPropertyHeader
|
||||
},
|
||||
text: partitionKeyPropertyHeader"
|
||||
tabindex="0"
|
||||
></th>
|
||||
<!-- /ko -->
|
||||
<th
|
||||
class="refreshColHeader"
|
||||
role="button"
|
||||
aria-label="Refresh documents"
|
||||
data-bind="event: { keydown: onRefreshButtonKeyDown }"
|
||||
>
|
||||
<img
|
||||
class="refreshcol"
|
||||
src="/refresh-cosmos.svg"
|
||||
data-bind="click: refreshDocumentsGrid"
|
||||
alt="Refresh documents"
|
||||
tabindex="0"
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbodycontent">
|
||||
<!-- ko foreach: documentIds -->
|
||||
<tr
|
||||
class="pointer accessibleListElement"
|
||||
data-bind="
|
||||
click: $data.click,
|
||||
css: {
|
||||
gridRowSelected: $parent.selectedDocumentId && $parent.selectedDocumentId() && $parent.selectedDocumentId().rid === $data.rid,
|
||||
gridRowHighlighted: $parent.accessibleDocumentList.currentItem() && $parent.accessibleDocumentList.currentItem().rid === $data.rid
|
||||
}"
|
||||
tabindex="0"
|
||||
>
|
||||
<td class="tabdocumentsGridElement"><a data-bind="text: $data.id, attr: { title: $data.id }"></a></td>
|
||||
<!-- ko if: $data.partitionKeyProperty -->
|
||||
<td class="tabdocumentsGridElement" colspan="2">
|
||||
<a
|
||||
data-bind="text: $data.stringPartitionKeyValue, attr: { title: $data.stringPartitionKeyValue }"
|
||||
></a>
|
||||
</td>
|
||||
<!-- /ko -->
|
||||
</tr>
|
||||
<!-- /ko -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="loadMore">
|
||||
<a role="link" data-bind="click: loadNextPage, event: { keypress: onLoadMoreKeyInput }" tabindex="0"
|
||||
>Load more</a
|
||||
>
|
||||
</div>
|
||||
<!-- Document Ids - End -->
|
||||
<!-- Splitter -->
|
||||
</div>
|
||||
<div class="splitter ui-resizable-handle ui-resizable-e colResizePointer" id="h_splitter2"></div>
|
||||
</div>
|
||||
<div class="documentWaterMark" data-bind="visible: !shouldShowEditor()">
|
||||
<p><img src="/DocumentWaterMark.svg" alt="Document WaterMark" /></p>
|
||||
<p class="documentWaterMarkText">Create new or work with existing document(s).</p>
|
||||
</div>
|
||||
<!-- Editor - Start -->
|
||||
<json-editor
|
||||
class="editorDivContent"
|
||||
data-bind="visible: shouldShowEditor, css: { mongoDocumentEditor: isPreferredApiMongoDB }"
|
||||
params="{content: initialDocumentContent, isReadOnly: false,lineNumbers: 'on',ariaLabel: 'Document editor',
|
||||
updatedContent: selectedDocumentContent}"
|
||||
></json-editor>
|
||||
<!-- Editor - End -->
|
||||
</div>
|
||||
<!-- Ids and Editor - End -->
|
||||
</div>
|
||||
196
src/Explorer/Tabs/DocumentsTab.test.ts
Normal file
196
src/Explorer/Tabs/DocumentsTab.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import DocumentsTab from "./DocumentsTab";
|
||||
import { DataAccessUtility } from "../../Platform/Portal/DataAccessUtility";
|
||||
import Explorer from "../Explorer";
|
||||
import DocumentClientUtilityBase from "../../Common/DocumentClientUtilityBase";
|
||||
|
||||
jest.mock("./NotebookTab");
|
||||
|
||||
describe("Documents tab", () => {
|
||||
describe("buildQuery", () => {
|
||||
it("should generate the right select query for SQL API", () => {
|
||||
const documentsTab = new DocumentsTab({
|
||||
partitionKey: null,
|
||||
documentIds: ko.observableArray<ViewModels.DocumentId>(),
|
||||
tabKind: ViewModels.CollectionTabKind.Documents,
|
||||
title: "",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable<boolean>(false),
|
||||
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(documentsTab.buildQuery("")).toContain("select");
|
||||
});
|
||||
});
|
||||
|
||||
describe("showPartitionKey", () => {
|
||||
const explorer = new Explorer({
|
||||
documentClientUtility: null,
|
||||
notificationsClient: null,
|
||||
isEmulator: false
|
||||
});
|
||||
|
||||
const mongoExplorer = new Explorer({
|
||||
documentClientUtility: null,
|
||||
notificationsClient: null,
|
||||
isEmulator: false
|
||||
});
|
||||
mongoExplorer.defaultExperience(Constants.DefaultAccountExperience.MongoDB);
|
||||
|
||||
const collectionWithoutPartitionKey = <ViewModels.Collection>(<unknown>{
|
||||
id: ko.observable<string>("foo"),
|
||||
database: {
|
||||
id: ko.observable<string>("foo")
|
||||
},
|
||||
container: explorer
|
||||
});
|
||||
|
||||
const collectionWithSystemPartitionKey = <ViewModels.Collection>(<unknown>{
|
||||
id: ko.observable<string>("foo"),
|
||||
database: {
|
||||
id: ko.observable<string>("foo")
|
||||
},
|
||||
partitionKey: {
|
||||
paths: ["/foo"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
systemKey: true
|
||||
},
|
||||
container: explorer
|
||||
});
|
||||
|
||||
const collectionWithNonSystemPartitionKey = <ViewModels.Collection>(<unknown>{
|
||||
id: ko.observable<string>("foo"),
|
||||
database: {
|
||||
id: ko.observable<string>("foo")
|
||||
},
|
||||
partitionKey: {
|
||||
paths: ["/foo"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
systemKey: false
|
||||
},
|
||||
container: explorer
|
||||
});
|
||||
|
||||
const mongoCollectionWithSystemPartitionKey = <ViewModels.Collection>(<unknown>{
|
||||
id: ko.observable<string>("foo"),
|
||||
database: {
|
||||
id: ko.observable<string>("foo")
|
||||
},
|
||||
partitionKey: {
|
||||
paths: ["/foo"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
systemKey: true
|
||||
},
|
||||
container: mongoExplorer
|
||||
});
|
||||
|
||||
it("should be false for null or undefined collection", () => {
|
||||
const documentsTab = new DocumentsTab({
|
||||
partitionKey: null,
|
||||
documentIds: ko.observableArray<ViewModels.DocumentId>(),
|
||||
tabKind: ViewModels.CollectionTabKind.Documents,
|
||||
title: "",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable<boolean>(false),
|
||||
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(documentsTab.showPartitionKey).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false for null or undefined partitionKey", () => {
|
||||
const documentsTab = new DocumentsTab({
|
||||
collection: collectionWithoutPartitionKey,
|
||||
partitionKey: null,
|
||||
documentIds: ko.observableArray<ViewModels.DocumentId>(),
|
||||
tabKind: ViewModels.CollectionTabKind.Documents,
|
||||
title: "",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable<boolean>(false),
|
||||
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(documentsTab.showPartitionKey).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true for non-Mongo accounts with system partitionKey", () => {
|
||||
const documentsTab = new DocumentsTab({
|
||||
collection: collectionWithSystemPartitionKey,
|
||||
partitionKey: null,
|
||||
documentIds: ko.observableArray<ViewModels.DocumentId>(),
|
||||
tabKind: ViewModels.CollectionTabKind.Documents,
|
||||
title: "",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable<boolean>(false),
|
||||
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(documentsTab.showPartitionKey).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false for Mongo accounts with system partitionKey", () => {
|
||||
const documentsTab = new DocumentsTab({
|
||||
collection: mongoCollectionWithSystemPartitionKey,
|
||||
partitionKey: null,
|
||||
documentIds: ko.observableArray<ViewModels.DocumentId>(),
|
||||
tabKind: ViewModels.CollectionTabKind.Documents,
|
||||
title: "",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable<boolean>(false),
|
||||
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(documentsTab.showPartitionKey).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true for non-system partitionKey", () => {
|
||||
const documentsTab = new DocumentsTab({
|
||||
collection: collectionWithNonSystemPartitionKey,
|
||||
partitionKey: null,
|
||||
documentIds: ko.observableArray<ViewModels.DocumentId>(),
|
||||
tabKind: ViewModels.CollectionTabKind.Documents,
|
||||
title: "",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable<boolean>(false),
|
||||
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(documentsTab.showPartitionKey).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
977
src/Explorer/Tabs/DocumentsTab.ts
Normal file
977
src/Explorer/Tabs/DocumentsTab.ts
Normal file
@@ -0,0 +1,977 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { AccessibleVerticalList } from "../Tree/AccessibleVerticalList";
|
||||
import { KeyCodes } from "../../Common/Constants";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import DocumentId from "../Tree/DocumentId";
|
||||
import editable from "../../Common/EditableUtility";
|
||||
import * as HeadersUtility from "../../Common/HeadersUtility";
|
||||
import TabsBase from "./TabsBase";
|
||||
import { DocumentsGridMetrics } from "../../Common/Constants";
|
||||
import { QueryUtils } from "../../Utils/QueryUtils";
|
||||
import { Splitter, SplitterBounds, SplitterDirection } from "../../Common/Splitter";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import Toolbar from "../Controls/Toolbar/Toolbar";
|
||||
import NewDocumentIcon from "../../../images/NewDocument.svg";
|
||||
import SaveIcon from "../../../images/save-cosmos.svg";
|
||||
import DiscardIcon from "../../../images/discard.svg";
|
||||
import DeleteDocumentIcon from "../../../images/DeleteDocument.svg";
|
||||
import UploadIcon from "../../../images/Upload_16x16.svg";
|
||||
import SynapseIcon from "../../../images/synapse-link.svg";
|
||||
import { extractPartitionKey, PartitionKeyDefinition, QueryIterator, ItemDefinition, Resource } from "@azure/cosmos";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
|
||||
export default class DocumentsTab extends TabsBase implements ViewModels.DocumentsTab {
|
||||
public selectedDocumentId: ko.Observable<ViewModels.DocumentId>;
|
||||
public selectedDocumentContent: ViewModels.Editable<string>;
|
||||
public initialDocumentContent: ko.Observable<string>;
|
||||
public documentContentsGridId: string;
|
||||
public documentContentsContainerId: string;
|
||||
public filterContent: ko.Observable<string>;
|
||||
public appliedFilter: ko.Observable<string>;
|
||||
public lastFilterContents: ko.ObservableArray<string>;
|
||||
public isFilterExpanded: ko.Observable<boolean>;
|
||||
public isFilterCreated: ko.Observable<boolean>;
|
||||
public applyFilterButton: ViewModels.Button;
|
||||
public isEditorDirty: ko.Computed<boolean>;
|
||||
public editorState: ko.Observable<ViewModels.DocumentExplorerState>;
|
||||
public toolbarViewModel = ko.observable<Toolbar>();
|
||||
public newDocumentButton: ViewModels.Button;
|
||||
public saveNewDocumentButton: ViewModels.Button;
|
||||
public saveExisitingDocumentButton: ViewModels.Button;
|
||||
public discardNewDocumentChangesButton: ViewModels.Button;
|
||||
public discardExisitingDocumentChangesButton: ViewModels.Button;
|
||||
public deleteExisitingDocumentButton: ViewModels.Button;
|
||||
public displayedError: ko.Observable<string>;
|
||||
public accessibleDocumentList: AccessibleVerticalList;
|
||||
public dataContentsGridScrollHeight: ko.Observable<string>;
|
||||
public isPreferredApiMongoDB: boolean;
|
||||
public shouldShowEditor: ko.Computed<boolean>;
|
||||
public splitter: Splitter;
|
||||
public showPartitionKey: boolean;
|
||||
public idHeader: string;
|
||||
|
||||
// TODO need to refactor
|
||||
public partitionKey: DataModels.PartitionKey;
|
||||
public partitionKeyPropertyHeader: string;
|
||||
public partitionKeyProperty: string;
|
||||
public documentIds: ko.ObservableArray<ViewModels.DocumentId>;
|
||||
|
||||
private _documentsIterator: QueryIterator<ItemDefinition & Resource>;
|
||||
private _resourceTokenPartitionKey: string;
|
||||
protected _selfLink: string;
|
||||
|
||||
constructor(options: ViewModels.DocumentsTabOptions) {
|
||||
super(options);
|
||||
this.isPreferredApiMongoDB = !!this.collection
|
||||
? this.collection.container.isPreferredApiMongoDB()
|
||||
: options.isPreferredApiMongoDB;
|
||||
|
||||
this.idHeader = this.isPreferredApiMongoDB ? "_id" : "id";
|
||||
|
||||
this.documentContentsGridId = `documentContentsGrid${this.tabId}`;
|
||||
this.documentContentsContainerId = `documentContentsContainer${this.tabId}`;
|
||||
this.editorState = ko.observable<ViewModels.DocumentExplorerState>(
|
||||
ViewModels.DocumentExplorerState.noDocumentSelected
|
||||
);
|
||||
this.selectedDocumentId = ko.observable<ViewModels.DocumentId>();
|
||||
this.selectedDocumentContent = editable.observable<string>("");
|
||||
this.initialDocumentContent = ko.observable<string>("");
|
||||
this.partitionKey = options.partitionKey || (this.collection && this.collection.partitionKey);
|
||||
this._resourceTokenPartitionKey = options.resourceTokenPartitionKey;
|
||||
this.documentIds = options.documentIds;
|
||||
this._selfLink = options.selfLink || (this.collection && this.collection.self);
|
||||
|
||||
this.partitionKeyPropertyHeader =
|
||||
(this.collection && this.collection.partitionKeyPropertyHeader) || this._getPartitionKeyPropertyHeader();
|
||||
this.partitionKeyProperty = !!this.partitionKeyPropertyHeader
|
||||
? this.partitionKeyPropertyHeader
|
||||
.replace(/[/]+/g, ".")
|
||||
.substr(1)
|
||||
.replace(/[']+/g, "")
|
||||
: null;
|
||||
|
||||
this.isFilterExpanded = ko.observable<boolean>(false);
|
||||
this.isFilterCreated = ko.observable<boolean>(true);
|
||||
this.filterContent = ko.observable<string>("");
|
||||
this.appliedFilter = ko.observable<string>("");
|
||||
this.displayedError = ko.observable<string>("");
|
||||
this.lastFilterContents = ko.observableArray<string>([
|
||||
'WHERE c.id = "foo"',
|
||||
"ORDER BY c._ts DESC",
|
||||
'WHERE c.id = "foo" ORDER BY c._ts DESC'
|
||||
]);
|
||||
|
||||
this.dataContentsGridScrollHeight = ko.observable<string>(null);
|
||||
|
||||
// initialize splitter only after template has been loaded so dom elements are accessible
|
||||
super.onTemplateReady((isTemplateReady: boolean) => {
|
||||
if (isTemplateReady) {
|
||||
const tabContainer: HTMLElement = document.getElementById("content");
|
||||
const splitterBounds: SplitterBounds = {
|
||||
min: Constants.DocumentsGridMetrics.DocumentEditorMinWidthRatio * tabContainer.clientWidth,
|
||||
max: Constants.DocumentsGridMetrics.DocumentEditorMaxWidthRatio * tabContainer.clientWidth
|
||||
};
|
||||
this.splitter = new Splitter({
|
||||
splitterId: "h_splitter2",
|
||||
leftId: this.documentContentsContainerId,
|
||||
bounds: splitterBounds,
|
||||
direction: SplitterDirection.Vertical
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.accessibleDocumentList = new AccessibleVerticalList(this.documentIds());
|
||||
this.accessibleDocumentList.setOnSelect(
|
||||
(selectedDocument: DocumentId) => selectedDocument && selectedDocument.click()
|
||||
);
|
||||
this.selectedDocumentId.subscribe((newSelectedDocumentId: DocumentId) =>
|
||||
this.accessibleDocumentList.updateCurrentItem(newSelectedDocumentId)
|
||||
);
|
||||
this.documentIds.subscribe((newDocuments: DocumentId[]) => {
|
||||
this.accessibleDocumentList.updateItemList(newDocuments);
|
||||
if (newDocuments.length > 0) {
|
||||
this.dataContentsGridScrollHeight(
|
||||
newDocuments.length * DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px"
|
||||
);
|
||||
} else {
|
||||
this.dataContentsGridScrollHeight(
|
||||
DocumentsGridMetrics.IndividualRowHeight + DocumentsGridMetrics.BufferHeight + "px"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.isEditorDirty = ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.noDocumentSelected:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
return false;
|
||||
|
||||
case ViewModels.DocumentExplorerState.newDocumentValid:
|
||||
case ViewModels.DocumentExplorerState.newDocumentInvalid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
|
||||
return true;
|
||||
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
return (
|
||||
this.selectedDocumentContent.getEditableOriginalValue() !==
|
||||
this.selectedDocumentContent.getEditableCurrentValue()
|
||||
);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.newDocumentButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.noDocumentSelected:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.saveNewDocumentButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.newDocumentValid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.newDocumentValid:
|
||||
case ViewModels.DocumentExplorerState.newDocumentInvalid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
};
|
||||
|
||||
this.discardNewDocumentChangesButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.newDocumentValid:
|
||||
case ViewModels.DocumentExplorerState.newDocumentInvalid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.newDocumentValid:
|
||||
case ViewModels.DocumentExplorerState.newDocumentInvalid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
};
|
||||
|
||||
this.saveExisitingDocumentButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
};
|
||||
|
||||
this.discardExisitingDocumentChangesButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
};
|
||||
|
||||
this.deleteExisitingDocumentButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
switch (this.editorState()) {
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentNoEdits:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid:
|
||||
case ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid:
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
};
|
||||
|
||||
this.applyFilterButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
this.buildCommandBarOptions();
|
||||
this.shouldShowEditor = ko.computed<boolean>(() => {
|
||||
const documentHasContent: boolean =
|
||||
this.selectedDocumentContent() != null && this.selectedDocumentContent().length > 0;
|
||||
const isNewDocument: boolean =
|
||||
this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid ||
|
||||
this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid;
|
||||
|
||||
return documentHasContent || isNewDocument;
|
||||
});
|
||||
this.selectedDocumentContent.subscribe((newContent: string) => this._onEditorContentChange(newContent));
|
||||
|
||||
this.showPartitionKey = this._shouldShowPartitionKey();
|
||||
}
|
||||
|
||||
private _shouldShowPartitionKey(): boolean {
|
||||
if (!this.collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.collection.partitionKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.collection.partitionKey.systemKey && this.isPreferredApiMongoDB) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public onShowFilterClick(): Q.Promise<any> {
|
||||
this.isFilterCreated(true);
|
||||
this.isFilterExpanded(true);
|
||||
|
||||
$(".filterDocExpanded").addClass("active");
|
||||
$("#content").addClass("active");
|
||||
$(".querydropdown").focus();
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onHideFilterClick(): Q.Promise<any> {
|
||||
this.isFilterExpanded(false);
|
||||
|
||||
$(".filterDocExpanded").removeClass("active");
|
||||
$("#content").removeClass("active");
|
||||
$(".queryButton").focus();
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onCloseButtonKeyDown = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.onHideFilterClick();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public onApplyFilterClick(): Q.Promise<any> {
|
||||
// clear documents grid
|
||||
this.documentIds([]);
|
||||
return this.createIterator()
|
||||
.then(
|
||||
// reset iterator
|
||||
iterator => {
|
||||
this._documentsIterator = iterator;
|
||||
}
|
||||
)
|
||||
.then(
|
||||
// load documents
|
||||
() => {
|
||||
return this.loadNextPage();
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
// collapse filter
|
||||
this.appliedFilter(this.filterContent());
|
||||
this.isFilterExpanded(false);
|
||||
const focusElement = document.getElementById("errorStatusIcon");
|
||||
focusElement && focusElement.focus();
|
||||
})
|
||||
.catch(reason => {
|
||||
const message = ErrorParserUtility.parse(reason)[0].message;
|
||||
window.alert(message);
|
||||
});
|
||||
}
|
||||
|
||||
public refreshDocumentsGrid(): Q.Promise<any> {
|
||||
return this.onApplyFilterClick();
|
||||
}
|
||||
|
||||
public onRefreshButtonKeyDown = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.refreshDocumentsGrid();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public onDocumentIdClick(clickedDocumentId: ViewModels.DocumentId): Q.Promise<any> {
|
||||
if (this.editorState() !== ViewModels.DocumentExplorerState.noDocumentSelected) {
|
||||
return Q();
|
||||
}
|
||||
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onNewDocumentClick = (): Q.Promise<any> => {
|
||||
if (this.isEditorDirty() && !this._isIgnoreDirtyEditor()) {
|
||||
return Q();
|
||||
}
|
||||
this.selectedDocumentId(null);
|
||||
|
||||
const defaultDocument: string = this.renderObjectForEditor({ id: "replace_with_new_document_id" }, null, 4);
|
||||
this.initialDocumentContent(defaultDocument);
|
||||
this.selectedDocumentContent.setBaseline(defaultDocument);
|
||||
this.editorState(ViewModels.DocumentExplorerState.newDocumentValid);
|
||||
|
||||
return Q();
|
||||
};
|
||||
|
||||
public onSaveNewDocumentClick = (): Q.Promise<any> => {
|
||||
this.isExecutionError(false);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
const document = JSON.parse(this.selectedDocumentContent());
|
||||
this.isExecuting(true);
|
||||
return this.documentClientUtility
|
||||
.createDocument(this.collection, document)
|
||||
.then(
|
||||
(savedDocument: any) => {
|
||||
const value: string = this.renderObjectForEditor(savedDocument || {}, null, 4);
|
||||
this.selectedDocumentContent.setBaseline(value);
|
||||
this.initialDocumentContent(value);
|
||||
const partitionKeyValueArray = extractPartitionKey(
|
||||
savedDocument,
|
||||
this.partitionKey as PartitionKeyDefinition
|
||||
);
|
||||
const partitionKeyValue = partitionKeyValueArray && partitionKeyValueArray[0];
|
||||
let id = new DocumentId(this, savedDocument, partitionKeyValue);
|
||||
let ids = this.documentIds();
|
||||
ids.push(id);
|
||||
|
||||
this.selectedDocumentId(id);
|
||||
this.documentIds(ids);
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CreateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
reason => {
|
||||
this.isExecutionError(true);
|
||||
const message = ErrorParserUtility.parse(reason)[0].message;
|
||||
window.alert(message);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public onRevertNewDocumentClick = (): Q.Promise<any> => {
|
||||
this.initialDocumentContent("");
|
||||
this.selectedDocumentContent("");
|
||||
this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
|
||||
return Q();
|
||||
};
|
||||
|
||||
public onSaveExisitingDocumentClick = (): Q.Promise<any> => {
|
||||
const selectedDocumentId = this.selectedDocumentId();
|
||||
const documentContent = JSON.parse(this.selectedDocumentContent());
|
||||
|
||||
const partitionKeyValueArray = extractPartitionKey(documentContent, this.partitionKey as PartitionKeyDefinition);
|
||||
const partitionKeyValue = partitionKeyValueArray && partitionKeyValueArray[0];
|
||||
|
||||
selectedDocumentId.partitionKeyValue = partitionKeyValue;
|
||||
|
||||
this.isExecutionError(false);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
this.isExecuting(true);
|
||||
return this.documentClientUtility
|
||||
.updateDocument(this.collection, selectedDocumentId, documentContent)
|
||||
.then(
|
||||
(updatedDocument: any) => {
|
||||
const value: string = this.renderObjectForEditor(updatedDocument || {}, null, 4);
|
||||
this.selectedDocumentContent.setBaseline(value);
|
||||
this.initialDocumentContent(value);
|
||||
this.documentIds().forEach((documentId: ViewModels.DocumentId) => {
|
||||
if (documentId.rid === updatedDocument._rid) {
|
||||
documentId.id(updatedDocument.id);
|
||||
}
|
||||
});
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
reason => {
|
||||
this.isExecutionError(true);
|
||||
const message = ErrorParserUtility.parse(reason)[0].message;
|
||||
window.alert(message);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public onRevertExisitingDocumentClick = (): Q.Promise<any> => {
|
||||
this.selectedDocumentContent.setBaseline(this.initialDocumentContent());
|
||||
this.initialDocumentContent.valueHasMutated();
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
|
||||
return Q();
|
||||
};
|
||||
|
||||
public onDeleteExisitingDocumentClick = (): Q.Promise<any> => {
|
||||
const selectedDocumentId = this.selectedDocumentId();
|
||||
const msg = !this.isPreferredApiMongoDB
|
||||
? "Are you sure you want to delete the selected item ?"
|
||||
: "Are you sure you want to delete the selected document ?";
|
||||
|
||||
if (window.confirm(msg)) {
|
||||
return this._deleteDocument(selectedDocumentId);
|
||||
}
|
||||
|
||||
return Q();
|
||||
};
|
||||
|
||||
public onValidDocumentEdit(): Q.Promise<any> {
|
||||
if (
|
||||
this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid ||
|
||||
this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid
|
||||
) {
|
||||
this.editorState(ViewModels.DocumentExplorerState.newDocumentValid);
|
||||
return Q();
|
||||
}
|
||||
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid);
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onInvalidDocumentEdit(): Q.Promise<any> {
|
||||
if (
|
||||
this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid ||
|
||||
this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid
|
||||
) {
|
||||
this.editorState(ViewModels.DocumentExplorerState.newDocumentInvalid);
|
||||
return Q();
|
||||
}
|
||||
|
||||
if (
|
||||
this.editorState() === ViewModels.DocumentExplorerState.exisitingDocumentNoEdits ||
|
||||
this.editorState() === ViewModels.DocumentExplorerState.exisitingDocumentDirtyValid
|
||||
) {
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentDirtyInvalid);
|
||||
return Q();
|
||||
}
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onTabClick(): Q.Promise<any> {
|
||||
return super.onTabClick().then(() => {
|
||||
this.collection && this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents);
|
||||
});
|
||||
}
|
||||
|
||||
public onActivate(): Q.Promise<any> {
|
||||
return super.onActivate().then(() => {
|
||||
if (this._documentsIterator) {
|
||||
return Q.resolve(this._documentsIterator);
|
||||
}
|
||||
|
||||
return this.createIterator().then(
|
||||
(iterator: QueryIterator<ItemDefinition & Resource>) => {
|
||||
this._documentsIterator = iterator;
|
||||
return this.loadNextPage();
|
||||
},
|
||||
error => {
|
||||
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
error: error
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public onRefreshClick(): Q.Promise<any> {
|
||||
return this.refreshDocumentsGrid().then(() => {
|
||||
this.selectedDocumentContent("");
|
||||
this.selectedDocumentId(null);
|
||||
this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
});
|
||||
}
|
||||
private _isIgnoreDirtyEditor = (): boolean => {
|
||||
var msg: string = "Changes will be lost. Do you want to continue?";
|
||||
return window.confirm(msg);
|
||||
};
|
||||
|
||||
protected __deleteDocument(documentId: ViewModels.DocumentId): Q.Promise<any> {
|
||||
return this.documentClientUtility.deleteDocument(this.collection, documentId);
|
||||
}
|
||||
|
||||
private _deleteDocument(selectedDocumentId: ViewModels.DocumentId): Q.Promise<any> {
|
||||
this.isExecutionError(false);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
this.isExecuting(true);
|
||||
return this.__deleteDocument(selectedDocumentId)
|
||||
.then(
|
||||
(result: any) => {
|
||||
this.documentIds.remove((documentId: ViewModels.DocumentId) => documentId.rid === selectedDocumentId.rid);
|
||||
this.selectedDocumentContent("");
|
||||
this.selectedDocumentId(null);
|
||||
this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.DeleteDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
reason => {
|
||||
this.isExecutionError(true);
|
||||
console.error(reason);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.DeleteDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
}
|
||||
|
||||
public createIterator(): Q.Promise<QueryIterator<ItemDefinition & Resource>> {
|
||||
let filters = this.lastFilterContents();
|
||||
const filter: string = this.filterContent().trim();
|
||||
const query: string = this.buildQuery(filter);
|
||||
let options: any = {};
|
||||
options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey();
|
||||
|
||||
if (this._resourceTokenPartitionKey) {
|
||||
options.partitionKey = this._resourceTokenPartitionKey;
|
||||
}
|
||||
|
||||
return this.documentClientUtility.queryDocuments(this.collection.databaseId, this.collection.id(), query, options);
|
||||
}
|
||||
|
||||
public selectDocument(documentId: ViewModels.DocumentId): Q.Promise<any> {
|
||||
this.selectedDocumentId(documentId);
|
||||
return this.documentClientUtility.readDocument(this.collection, documentId).then((content: any) => {
|
||||
this.initDocumentEditor(documentId, content);
|
||||
});
|
||||
}
|
||||
|
||||
public loadNextPage(): Q.Promise<any> {
|
||||
this.isExecuting(true);
|
||||
this.isExecutionError(false);
|
||||
return this._loadNextPageInternal()
|
||||
.then(
|
||||
(documentsIdsResponse = []) => {
|
||||
const currentDocuments = this.documentIds();
|
||||
const currentDocumentsRids = currentDocuments.map(currentDocument => currentDocument.rid);
|
||||
const nextDocumentIds = documentsIdsResponse
|
||||
// filter documents already loaded in observable
|
||||
.filter((d: any) => {
|
||||
return currentDocumentsRids.indexOf(d._rid) < 0;
|
||||
})
|
||||
// map raw response to view model
|
||||
.map((rawDocument: any) => {
|
||||
const partitionKeyValue = rawDocument._partitionKeyValue;
|
||||
return <ViewModels.DocumentId>new DocumentId(this, rawDocument, partitionKeyValue);
|
||||
});
|
||||
|
||||
const merged = currentDocuments.concat(nextDocumentIds);
|
||||
this.documentIds(merged);
|
||||
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
},
|
||||
error => {
|
||||
this.isExecutionError(true);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
typeof error === "string" ? error : JSON.stringify(error)
|
||||
);
|
||||
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
error: error
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
}
|
||||
|
||||
public onLoadMoreKeyInput = (source: any, event: KeyboardEvent): void => {
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
const focusElement = document.getElementById(this.documentContentsGridId);
|
||||
this.loadNextPage();
|
||||
focusElement && focusElement.focus();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
protected _loadNextPageInternal(): Q.Promise<DataModels.DocumentId[]> {
|
||||
return Q(this._documentsIterator.fetchNext().then(response => response.resources));
|
||||
}
|
||||
|
||||
protected _onEditorContentChange(newContent: string) {
|
||||
try {
|
||||
let parsed: any = JSON.parse(newContent);
|
||||
this.onValidDocumentEdit();
|
||||
} catch (e) {
|
||||
this.onInvalidDocumentEdit();
|
||||
}
|
||||
}
|
||||
|
||||
public initDocumentEditor(documentId: ViewModels.DocumentId, documentContent: any): Q.Promise<any> {
|
||||
if (documentId) {
|
||||
const content: string = this.renderObjectForEditor(documentContent, null, 4);
|
||||
this.selectedDocumentContent.setBaseline(content);
|
||||
this.initialDocumentContent(content);
|
||||
const newState = documentId
|
||||
? ViewModels.DocumentExplorerState.exisitingDocumentNoEdits
|
||||
: ViewModels.DocumentExplorerState.newDocumentValid;
|
||||
this.editorState(newState);
|
||||
}
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
public buildQuery(filter: string): string {
|
||||
return QueryUtils.buildDocumentsQuery(filter, this.partitionKeyProperty, this.partitionKey);
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const buttons: ViewModels.NavbarButtonConfig[] = [];
|
||||
const label = !this.isPreferredApiMongoDB ? "New Item" : "New Document";
|
||||
if (this.newDocumentButton.visible()) {
|
||||
buttons.push({
|
||||
iconSrc: NewDocumentIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onNewDocumentClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.newDocumentButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.saveNewDocumentButton.visible()) {
|
||||
const label = "Save";
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onSaveNewDocumentClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveNewDocumentButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.discardNewDocumentChangesButton.visible()) {
|
||||
const label = "Discard";
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onRevertNewDocumentClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.discardNewDocumentChangesButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.saveExisitingDocumentButton.visible()) {
|
||||
const label = "Update";
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onSaveExisitingDocumentClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveExisitingDocumentButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.discardExisitingDocumentChangesButton.visible()) {
|
||||
const label = "Discard";
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onRevertExisitingDocumentClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.discardExisitingDocumentChangesButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.deleteExisitingDocumentButton.visible()) {
|
||||
const label = "Delete";
|
||||
buttons.push({
|
||||
iconSrc: DeleteDocumentIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onDeleteExisitingDocumentClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.deleteExisitingDocumentButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.isPreferredApiMongoDB) {
|
||||
buttons.push(DocumentsTab._createUploadButton(this.collection.container));
|
||||
}
|
||||
|
||||
const features = this.collection.container.features() || {};
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
ko.computed(() =>
|
||||
ko.toJSON([
|
||||
this.newDocumentButton.visible,
|
||||
this.newDocumentButton.enabled,
|
||||
this.saveNewDocumentButton.visible,
|
||||
this.saveNewDocumentButton.enabled,
|
||||
this.discardNewDocumentChangesButton.visible,
|
||||
this.discardNewDocumentChangesButton.enabled,
|
||||
this.saveExisitingDocumentButton.visible,
|
||||
this.saveExisitingDocumentButton.enabled,
|
||||
this.discardExisitingDocumentChangesButton.visible,
|
||||
this.discardExisitingDocumentChangesButton.enabled,
|
||||
this.deleteExisitingDocumentButton.visible,
|
||||
this.deleteExisitingDocumentButton.enabled
|
||||
])
|
||||
).subscribe(() => this.updateNavbarWithTabsButtons());
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
|
||||
private _getPartitionKeyPropertyHeader(): string {
|
||||
return (
|
||||
(this.partitionKey &&
|
||||
this.partitionKey.paths &&
|
||||
this.partitionKey.paths.length > 0 &&
|
||||
this.partitionKey.paths[0]) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public static _createUploadButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig {
|
||||
const label = "Upload Item";
|
||||
return {
|
||||
iconSrc: UploadIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: () => {
|
||||
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
|
||||
const focusElement = document.getElementById("itemImportLink");
|
||||
selectedCollection && container.uploadItemsPane.open();
|
||||
focusElement && focusElement.focus();
|
||||
},
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: true,
|
||||
disabled: container.isDatabaseNodeOrNoneSelected()
|
||||
};
|
||||
}
|
||||
}
|
||||
1
src/Explorer/Tabs/GalleryTab.html
Normal file
1
src/Explorer/Tabs/GalleryTab.html
Normal file
@@ -0,0 +1 @@
|
||||
<div style="height: 100%" data-bind="react:galleryComponentAdapter, setTemplateReady: true"></div>
|
||||
45
src/Explorer/Tabs/GalleryTab.tsx
Normal file
45
src/Explorer/Tabs/GalleryTab.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
import * as React from "react";
|
||||
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
|
||||
import { GalleryViewerContainerComponent } from "../../GalleryViewer/GalleryViewerComponent";
|
||||
|
||||
/**
|
||||
* Notebook gallery tab
|
||||
*/
|
||||
class GalleryComponentAdapter implements ReactAdapter {
|
||||
public parameters: ko.Computed<boolean>;
|
||||
constructor(private getContainer: () => ViewModels.Explorer) {}
|
||||
|
||||
public renderComponent(): JSX.Element {
|
||||
return this.parameters() ? <GalleryViewerContainerComponent container={this.getContainer()} /> : <></>;
|
||||
}
|
||||
}
|
||||
|
||||
export default class GalleryTab extends TabsBase implements ViewModels.Tab {
|
||||
private container: ViewModels.Explorer;
|
||||
private galleryComponentAdapter: GalleryComponentAdapter;
|
||||
|
||||
constructor(options: ViewModels.GalleryTabOptions) {
|
||||
super(options);
|
||||
this.container = options.container;
|
||||
this.galleryComponentAdapter = new GalleryComponentAdapter(() => this.getContainer());
|
||||
|
||||
this.galleryComponentAdapter.parameters = ko.computed<boolean>(() => {
|
||||
return this.isTemplateReady() && this.container.isNotebookEnabled();
|
||||
});
|
||||
}
|
||||
|
||||
protected getContainer(): ViewModels.Explorer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
}
|
||||
1
src/Explorer/Tabs/GraphTab.html
Normal file
1
src/Explorer/Tabs/GraphTab.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="graphExplorerContainer" role="tabpanel" data-bind="react:graphExplorerAdapter, attr:{ id: tabId }"></div>
|
||||
237
src/Explorer/Tabs/GraphTab.ts
Normal file
237
src/Explorer/Tabs/GraphTab.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Q from "q";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
import Toolbar from "../Controls/Toolbar/Toolbar";
|
||||
import { GraphExplorerAdapter } from "../Graph/GraphExplorerComponent/GraphExplorerAdapter";
|
||||
import { GraphAccessor, GraphExplorerError } from "../Graph/GraphExplorerComponent/GraphExplorer";
|
||||
import NewVertexIcon from "../../../images/NewVertex.svg";
|
||||
import StyleIcon from "../../../images/Style.svg";
|
||||
|
||||
export interface GraphIconMap {
|
||||
[key: string]: { data: string; format: string };
|
||||
}
|
||||
|
||||
export interface GraphConfig {
|
||||
nodeColor: ko.Observable<string>;
|
||||
nodeColorKey: ko.Observable<string>; // map property to node color. Takes precedence over nodeColor unless null
|
||||
linkColor: ko.Observable<string>;
|
||||
showNeighborType: ko.Observable<ViewModels.NeighborType>;
|
||||
nodeCaption: ko.Observable<string>;
|
||||
nodeSize: ko.Observable<number>;
|
||||
linkWidth: ko.Observable<number>;
|
||||
nodeIconKey: ko.Observable<string>;
|
||||
iconsMap: ko.Observable<GraphIconMap>;
|
||||
}
|
||||
|
||||
export default class GraphTab extends TabsBase implements ViewModels.Tab {
|
||||
// Graph default configuration
|
||||
public static readonly DEFAULT_NODE_CAPTION = "id";
|
||||
private static readonly LINK_COLOR = "#aaa";
|
||||
private static readonly NODE_SIZE = 10;
|
||||
private static readonly NODE_COLOR = "orange";
|
||||
private static readonly LINK_WIDTH = 1;
|
||||
private graphExplorerAdapter: GraphExplorerAdapter;
|
||||
private isNewVertexDisabled: ko.Observable<boolean>;
|
||||
private isPropertyEditing: ko.Observable<boolean>;
|
||||
private isGraphDisplayed: ko.Observable<boolean>;
|
||||
private graphAccessor: GraphAccessor;
|
||||
public toolbarViewModel: ko.Observable<Toolbar>;
|
||||
private graphConfig: GraphConfig;
|
||||
private graphConfigUiData: ViewModels.GraphConfigUiData;
|
||||
private isFilterQueryLoading: ko.Observable<boolean>;
|
||||
private isValidQuery: ko.Observable<boolean>;
|
||||
private newVertexPane: ViewModels.NewVertexPane;
|
||||
private graphStylingPane: ViewModels.GraphStylingPane;
|
||||
private collectionPartitionKeyProperty: string;
|
||||
|
||||
constructor(options: ViewModels.GraphTabOptions) {
|
||||
super(options);
|
||||
|
||||
this.newVertexPane = options.collection && options.collection.container.newVertexPane;
|
||||
this.graphStylingPane = options.collection && options.collection.container.graphStylingPane;
|
||||
this.collectionPartitionKeyProperty = options.collectionPartitionKeyProperty;
|
||||
|
||||
this.isNewVertexDisabled = ko.observable(false);
|
||||
this.isPropertyEditing = ko.observable(false);
|
||||
this.isGraphDisplayed = ko.observable(false);
|
||||
this.graphAccessor = null;
|
||||
this.graphConfig = GraphTab.createGraphConfig();
|
||||
// TODO Merge this with this.graphConfig
|
||||
this.graphConfigUiData = GraphTab.createGraphConfigUiData(this.graphConfig);
|
||||
this.graphExplorerAdapter = new GraphExplorerAdapter({
|
||||
onGraphAccessorCreated: (instance: GraphAccessor): void => {
|
||||
this.graphAccessor = instance;
|
||||
},
|
||||
onIsNewVertexDisabledChange: (isDisabled: boolean): void => {
|
||||
this.isNewVertexDisabled(isDisabled);
|
||||
this.updateNavbarWithTabsButtons();
|
||||
},
|
||||
onIsPropertyEditing: (isEditing: boolean) => {
|
||||
this.isPropertyEditing(isEditing);
|
||||
this.updateNavbarWithTabsButtons();
|
||||
},
|
||||
onIsGraphDisplayed: (isDisplayed: boolean) => {
|
||||
this.isGraphDisplayed(isDisplayed);
|
||||
this.updateNavbarWithTabsButtons();
|
||||
},
|
||||
onResetDefaultGraphConfigValues: () => this.setDefaultGraphConfigValues(),
|
||||
graphConfig: this.graphConfig,
|
||||
graphConfigUiData: this.graphConfigUiData,
|
||||
onIsFilterQueryLoading: (isFilterQueryLoading: boolean): void => this.isFilterQueryLoading(isFilterQueryLoading),
|
||||
onIsValidQuery: (isValidQuery: boolean): void => this.isValidQuery(isValidQuery),
|
||||
collectionPartitionKeyProperty: options.collectionPartitionKeyProperty,
|
||||
documentClientUtility: this.documentClientUtility,
|
||||
collectionRid: this.rid,
|
||||
collectionSelfLink: options.selfLink,
|
||||
graphBackendEndpoint: GraphTab.getGremlinEndpoint(options.account),
|
||||
databaseId: options.databaseId,
|
||||
collectionId: options.collectionId,
|
||||
masterKey: options.masterKey,
|
||||
onLoadStartKey: options.onLoadStartKey,
|
||||
onLoadStartKeyChange: (onLoadStartKey: number): void => {
|
||||
if (onLoadStartKey == null) {
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
},
|
||||
resourceId: options.account.id
|
||||
});
|
||||
|
||||
this.isFilterQueryLoading = ko.observable(false);
|
||||
this.isValidQuery = ko.observable(true);
|
||||
this.documentClientUtility = options.documentClientUtility;
|
||||
this.toolbarViewModel = ko.observable<Toolbar>();
|
||||
}
|
||||
|
||||
public static getGremlinEndpoint(account: ViewModels.DatabaseAccount): string {
|
||||
return account.properties.gremlinEndpoint
|
||||
? GraphTab.sanitizeHost(account.properties.gremlinEndpoint)
|
||||
: `${account.name}.graphs.azure.com:443/`;
|
||||
}
|
||||
|
||||
public onTabClick(): Q.Promise<any> {
|
||||
return super.onTabClick().then(() => {
|
||||
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Graph);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removing leading http|https and remove trailing /
|
||||
* @param url
|
||||
* @return
|
||||
*/
|
||||
private static sanitizeHost(url: string): string {
|
||||
if (!url) {
|
||||
return url;
|
||||
}
|
||||
return url.replace(/^(http|https):\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/* Command bar */
|
||||
private showNewVertexEditor(): void {
|
||||
this.newVertexPane.open();
|
||||
this.newVertexPane.setPartitionKeyProperty(this.collectionPartitionKeyProperty);
|
||||
// TODO Must update GraphExplorer properties
|
||||
this.newVertexPane.subscribeOnSubmitCreate((result: ViewModels.NewVertexData) => {
|
||||
this.newVertexPane.formErrors(null);
|
||||
this.newVertexPane.formErrorsDetails(null);
|
||||
this.graphAccessor.addVertex(result).then(
|
||||
() => {
|
||||
this.newVertexPane.cancel();
|
||||
},
|
||||
(error: GraphExplorerError) => {
|
||||
this.newVertexPane.formErrors(error.title);
|
||||
if (!!error.details) {
|
||||
this.newVertexPane.formErrorsDetails(error.details);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
public openStyling() {
|
||||
this.setDefaultGraphConfigValues();
|
||||
// Update the styling pane with this instance
|
||||
this.graphStylingPane.setData(this.graphConfigUiData);
|
||||
this.graphStylingPane.open();
|
||||
}
|
||||
|
||||
public static createGraphConfig(): GraphConfig {
|
||||
return {
|
||||
nodeColor: ko.observable(GraphTab.NODE_COLOR),
|
||||
nodeColorKey: ko.observable(null),
|
||||
linkColor: ko.observable(GraphTab.LINK_COLOR),
|
||||
showNeighborType: ko.observable(ViewModels.NeighborType.TARGETS_ONLY),
|
||||
nodeCaption: ko.observable(GraphTab.DEFAULT_NODE_CAPTION),
|
||||
nodeSize: ko.observable(GraphTab.NODE_SIZE),
|
||||
linkWidth: ko.observable(GraphTab.LINK_WIDTH),
|
||||
nodeIconKey: ko.observable(null),
|
||||
iconsMap: ko.observable({})
|
||||
};
|
||||
}
|
||||
|
||||
public static createGraphConfigUiData(graphConfig: GraphConfig): ViewModels.GraphConfigUiData {
|
||||
return {
|
||||
showNeighborType: ko.observable(graphConfig.showNeighborType()),
|
||||
nodeProperties: ko.observableArray([]),
|
||||
nodePropertiesWithNone: ko.observableArray([]),
|
||||
nodeCaptionChoice: ko.observable(graphConfig.nodeCaption()),
|
||||
nodeColorKeyChoice: ko.observable(graphConfig.nodeColorKey()),
|
||||
nodeIconChoice: ko.observable(graphConfig.nodeIconKey()),
|
||||
nodeIconSet: ko.observable(null)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure graph config values are not null
|
||||
*/
|
||||
private setDefaultGraphConfigValues() {
|
||||
// Assign default values if null
|
||||
if (this.graphConfigUiData.nodeCaptionChoice() === null && this.graphConfigUiData.nodeProperties().length > 1) {
|
||||
this.graphConfigUiData.nodeCaptionChoice(this.graphConfigUiData.nodeProperties()[0]);
|
||||
}
|
||||
if (
|
||||
this.graphConfigUiData.nodeColorKeyChoice() === null &&
|
||||
this.graphConfigUiData.nodePropertiesWithNone().length > 1
|
||||
) {
|
||||
this.graphConfigUiData.nodeColorKeyChoice(this.graphConfigUiData.nodePropertiesWithNone()[0]);
|
||||
}
|
||||
if (
|
||||
this.graphConfigUiData.nodeIconChoice() === null &&
|
||||
this.graphConfigUiData.nodePropertiesWithNone().length > 1
|
||||
) {
|
||||
this.graphConfigUiData.nodeIconChoice(this.graphConfigUiData.nodePropertiesWithNone()[0]);
|
||||
}
|
||||
}
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const label = "New Vertex";
|
||||
const buttons: ViewModels.NavbarButtonConfig[] = [
|
||||
{
|
||||
iconSrc: NewVertexIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: () => this.showNewVertexEditor(),
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: this.isNewVertexDisabled()
|
||||
}
|
||||
];
|
||||
buttons.push();
|
||||
if (this.isGraphDisplayed()) {
|
||||
const label = "Style";
|
||||
buttons.push({
|
||||
iconSrc: StyleIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: () => this.openStyling(),
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: this.isPropertyEditing()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
protected buildCommandBarOptions(): void {
|
||||
ko.computed(() => ko.toJSON([this.isNewVertexDisabled])).subscribe(() => this.updateNavbarWithTabsButtons());
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
}
|
||||
417
src/Explorer/Tabs/MongoDocumentsTab.html
Normal file
417
src/Explorer/Tabs/MongoDocumentsTab.html
Normal file
@@ -0,0 +1,417 @@
|
||||
<div
|
||||
class="tab-pane active tabdocuments flexContainer"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: tabId
|
||||
},
|
||||
visible: isActive"
|
||||
role="tabpanel"
|
||||
>
|
||||
<!-- Documents Tab Command Bar - Start -->
|
||||
<div class="contentdiv">
|
||||
<div class="tabCommandButton documentMenu">
|
||||
<!-- New Document - Start -->
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
click: onNewDocumentClick,
|
||||
visible: newDocumentButton.visible() && newDocumentButton.enabled()"
|
||||
>
|
||||
<img class="commandIcon" src="/createDoc.svg" />New Document
|
||||
</span>
|
||||
<span
|
||||
class="commandButton tabCommandDisabled"
|
||||
data-bind="
|
||||
visible: newDocumentButton.visible() && !newDocumentButton.enabled()"
|
||||
>
|
||||
<img class="commandIcon" src="/createDoc-disabled.svg" />New Document
|
||||
</span>
|
||||
<!-- New Document - End -->
|
||||
|
||||
<!-- New Query - Start -->
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
visible: !$root.isPreferredApiMongoDB,
|
||||
click: collection.onNewQueryClick"
|
||||
>
|
||||
<img class="commandIcon" src="/AddSqlQuery_16x16.svg" /> New SQL Query
|
||||
</span>
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
visible: $root.isPreferredApiMongoDB,
|
||||
click: collection.onNewMongoQueryClick"
|
||||
>
|
||||
<img class="commandIcon" src="/AddSqlQuery_16x16.svg" /> New Query
|
||||
</span>
|
||||
<!-- New Query - End -->
|
||||
|
||||
<!-- Save New - Start -->
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
click: onSaveNewDocumentClick,
|
||||
visible: saveNewDocumentButton.visible() && saveNewDocumentButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/save-cosmos.svg" />Save
|
||||
</span>
|
||||
<span
|
||||
class="commandButton tabCommandDisabled"
|
||||
data-bind="
|
||||
visible: saveNewDocumentButton.visible() && !saveNewDocumentButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/save-disabled.svg" />Save
|
||||
</span>
|
||||
<!-- Save New - End -->
|
||||
|
||||
<!-- Discard New - Start -->
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
click: onRevertNewDocumentClick,
|
||||
visible: discardNewDocumentChangesButton.visible() && discardNewDocumentChangesButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/discard.svg" />Discard
|
||||
</span>
|
||||
<span
|
||||
class="commandButton tabCommandDisabled"
|
||||
data-bind="
|
||||
visible: discardNewDocumentChangesButton.visible() && !discardNewDocumentChangesButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/discard-disabled.svg" />Discard
|
||||
</span>
|
||||
<!-- Discard New - End -->
|
||||
|
||||
<!-- Save Exisiting - Start -->
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
click: onSaveExisitingDocumentClick,
|
||||
visible: saveExisitingDocumentButton.visible() && saveExisitingDocumentButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/save-cosmos.svg" />Update
|
||||
</span>
|
||||
<span
|
||||
class="commandButton tabCommandDisabled"
|
||||
data-bind="
|
||||
visible: saveExisitingDocumentButton.visible() && !saveExisitingDocumentButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/save-disabled.svg" />Update
|
||||
</span>
|
||||
<!-- Save Exisiting - End -->
|
||||
|
||||
<!-- Discard Exisiting - Start -->
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
click: onRevertExisitingDocumentClick,
|
||||
visible: discardExisitingDocumentChangesButton.visible() && discardExisitingDocumentChangesButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/discard.svg" />Discard
|
||||
</span>
|
||||
<span
|
||||
class="commandButton tabCommandDisabled"
|
||||
data-bind="
|
||||
visible: discardExisitingDocumentChangesButton.visible() && !discardExisitingDocumentChangesButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/discard-disabled.svg" />Discard
|
||||
</span>
|
||||
<!-- Discard Exisiting - End -->
|
||||
|
||||
<!-- Delete Exisiting - Start -->
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
click: onDeleteExisitingDocumentClick,
|
||||
visible: deleteExisitingDocumentButton.visible() && deleteExisitingDocumentButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/delete.svg" />Delete
|
||||
</span>
|
||||
<span
|
||||
class="commandButton tabCommandDisabled"
|
||||
data-bind="
|
||||
visible: deleteExisitingDocumentButton.visible() && !deleteExisitingDocumentButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/delete-disabled.svg" />Delete
|
||||
</span>
|
||||
<!-- Delete Exisiting - End -->
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/html" id="toolbarItemTemplate">
|
||||
<!-- ko if: type === "action" -->
|
||||
<div class="toolbar-group" data-bind="visible: visible">
|
||||
<button class="toolbar-group-button" data-bind="hasFocus: focused, attr: {id: id, title: title, 'aria-label': displayName}, event: { mousedown: mouseDown, keydown: keyDown, keyup: keyUp }, enable: enabled">
|
||||
<div class="toolbar-group-button-icon">
|
||||
<div class="toolbar_icon" data-bind="icon: icon"></div>
|
||||
</div>
|
||||
<span data-bind="text: displayName"></span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko if: type === "toggle" -->
|
||||
<div class="toolbar-group" data-bind="visible: visible">
|
||||
<button class="toolbar-group-button toggle-button" data-bind="hasFocus: focused, attr: {id: id, title: title}, event: { mousedown: mouseDown, keydown: keyDown, keyup: keyUp }, enable: enabled">
|
||||
<div class="toolbar-group-button-icon" data-bind="css: { 'toggle-checked': checked }">
|
||||
<div class="toolbar_icon" data-bind="icon: icon"></div>
|
||||
</div>
|
||||
<span data-bind="text: displayName"></span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: type === "dropdown" -->
|
||||
<div class="toolbar-group" data-bind="visible: visible">
|
||||
<div class="dropdown" data-bind="attr: {id: (id + '-dropdown')}">
|
||||
<button role="menu" class="toolbar-group-button" data-bind="hasFocus: focused, attr: {id: id, title: title, 'aria-label': displayName}, event: { mousedown: mouseDown, keydown: keyDown, keyup: keyUp }, enable: enabled">
|
||||
<div class="toolbar-group-button-icon">
|
||||
<div class="toolbar_icon" data-bind="icon: icon"></div>
|
||||
</div>
|
||||
<span data-bind="text: displayName"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko if: type === "separator" -->
|
||||
<div class="toolbar-group vertical-separator" data-bind="visible: visible"></div>
|
||||
<!-- /ko -->
|
||||
</script>
|
||||
<!-- Documents Tab Command Bar - End -->
|
||||
<!-- ko if: false -->
|
||||
<!-- Messagebox Ok Cancel- Start -->
|
||||
<div class="messagebox-background">
|
||||
<div class="messagebox">
|
||||
<h2 class="messagebox-title">Title</h2>
|
||||
<div class="messagebox-text" tabindex="0">Text</div>
|
||||
<div class="messagebox-buttons">
|
||||
<div class="messagebox-buttons-container">
|
||||
<button value="ok" class="messagebox-button-primary">Ok</button>
|
||||
<button value="cancel" class="messagebox-button-default">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Messagebox OK Cancel - End -->
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- Filter - Start -->
|
||||
<div class="filterdivs">
|
||||
<!-- Read-only Filter - Start -->
|
||||
<div
|
||||
class="filterDocCollapsed"
|
||||
data-bind="
|
||||
visible: !isFilterExpanded() && !$root.isPreferredApiMongoDB()"
|
||||
>
|
||||
SELECT * FROM c
|
||||
<span
|
||||
data-bind="
|
||||
text: appliedFilter"
|
||||
></span>
|
||||
<button
|
||||
class="filterbtnstyle queryButton"
|
||||
data-bind="
|
||||
click: onShowFilterClick"
|
||||
>
|
||||
Edit Filter
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="filterDocCollapsed"
|
||||
data-bind="
|
||||
visible: !isFilterExpanded() && $root.isPreferredApiMongoDB()"
|
||||
>
|
||||
<span
|
||||
data-bind="
|
||||
visible: appliedFilter().length > 0"
|
||||
>Filter :
|
||||
</span>
|
||||
<span
|
||||
data-bind="
|
||||
visible: !appliedFilter().length > 0"
|
||||
>No filter applied</span
|
||||
>
|
||||
<span
|
||||
data-bind="
|
||||
text: appliedFilter"
|
||||
></span>
|
||||
<button
|
||||
class="filterbtnstyle queryButton"
|
||||
data-bind="
|
||||
click: onShowFilterClick"
|
||||
>
|
||||
Edit Filter
|
||||
</button>
|
||||
</div>
|
||||
<!-- Read-only Filter - End -->
|
||||
|
||||
<!-- Editable Filter - start -->
|
||||
<div
|
||||
class="filterDocExpanded"
|
||||
data-bind="
|
||||
visible: isFilterExpanded"
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<span
|
||||
class="filterspan"
|
||||
data-bind="
|
||||
visible: !$root.isPreferredApiMongoDB()"
|
||||
>
|
||||
SELECT * FROM c
|
||||
</span>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
list="filtersList"
|
||||
class="querydropdown"
|
||||
title="Type a query predicate or choose one from the list."
|
||||
data-bind="
|
||||
attr:{
|
||||
placeholder:$root.isPreferredApiMongoDB()?'Type a query predicate (e.g., {´a´:´foo´}), or choose one from the drop down list, or leave empty to query all documents.':'Type a query predicate (e.g., WHERE c.id=´1´), or choose one from the drop down list, or leave empty to query all documents.'
|
||||
},
|
||||
textInput: filterContent"
|
||||
/>
|
||||
|
||||
<datalist
|
||||
id="filtersList"
|
||||
data-bind="
|
||||
foreach: lastFilterContents"
|
||||
>
|
||||
<option
|
||||
data-bind="
|
||||
value: $data"
|
||||
>
|
||||
</option>
|
||||
</datalist>
|
||||
|
||||
<span class="filterbuttonpad">
|
||||
<button
|
||||
class="filterbtnstyle queryButton"
|
||||
data-bind="
|
||||
click: onApplyFilterClick,
|
||||
enable: applyFilterButton.enabled"
|
||||
>
|
||||
Apply Filter
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
class="filterclose"
|
||||
data-bind="
|
||||
click: onHideFilterClick"
|
||||
>
|
||||
<img src="/close-black.svg" style="height: 14px; width: 14px;" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Editable Filter - End -->
|
||||
</div>
|
||||
<!-- Filter - End -->
|
||||
|
||||
<!-- Ids and Editor - Start -->
|
||||
<div>
|
||||
<div class="row rowoverride documentsTabGridAndEditor">
|
||||
<div class="documentsGridHeaderContainer documentsContainer">
|
||||
<!-- ko if: !partitionKeyProperty -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<!-- ko if: $root.isPreferredApiMongoDB -->
|
||||
<td class="documentsGridHeader">_id</td>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: !$root.isPreferredApiMongoDB() -->
|
||||
<td class="documentsGridHeader">id</td>
|
||||
<!-- /ko -->
|
||||
<td class="refreshColHeader">
|
||||
<img class="refreshcol" src="/refresh-cosmos.svg" data-bind="click: refreshDocumentsGrid" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko if: partitionKeyProperty -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="documentsGridHeader fixedWidthHeader">_id</td>
|
||||
<td
|
||||
class="documentsGridHeader documentsGridPartition"
|
||||
data-bind="
|
||||
attr: {
|
||||
title: partitionKeyPropertyHeader
|
||||
},
|
||||
text: partitionKeyPropertyHeader"
|
||||
></td>
|
||||
<td class="refreshColHeader">
|
||||
<img class="refreshcol" src="/refresh-cosmos.svg" data-bind="click: refreshDocumentsGrid" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
<!-- Document Ids - Start -->
|
||||
<div
|
||||
class="tabdocuments scrollable"
|
||||
data-bind="
|
||||
attr: {
|
||||
id: documentContentsGridId,
|
||||
tabindex: collection.documentIds().length <= 0 ? -1 : 0
|
||||
},
|
||||
style: { height: dataContentsGridScrollHeight },
|
||||
event: { keydown: accessibleDocumentList.onKeyDown }"
|
||||
>
|
||||
<table class="table can-select table-hover dataTable">
|
||||
<tbody id="tbodycontent">
|
||||
<!-- ko foreach: documentIds -->
|
||||
<tr
|
||||
class="pointer accessibleListElement"
|
||||
data-bind="
|
||||
click: $data.click,
|
||||
css: {
|
||||
gridRowSelected: $parent.selectedDocumentId && $parent.selectedDocumentId() && $parent.selectedDocumentId().rid === $data.rid,
|
||||
gridRowHighlighted: $parent.accessibleDocumentList.currentItem() && $parent.accessibleDocumentList.currentItem().rid === $data.rid
|
||||
}"
|
||||
>
|
||||
<td style="width:82px;">
|
||||
<a
|
||||
data-bind="
|
||||
text: $data.id, attr: { title: $data.id }"
|
||||
></a>
|
||||
</td>
|
||||
<!-- ko if: $data.partitionKeyProperty -->
|
||||
<td><a data-bind="text: $data.partitionKeyValue, attr: { title: $data.partitionKeyValue }"></a></td>
|
||||
<!-- /ko -->
|
||||
</tr>
|
||||
<!-- /ko -->
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="loadMore">
|
||||
<a role="link" data-bind="click: loadNextPage, event: { keypress: onLoadMoreKeyInput }" tabindex="0"
|
||||
>Load more</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Document Ids - End -->
|
||||
|
||||
<!-- Editor - Start -->
|
||||
<div id="divcontent" style="float: left; width: calc(100% - 200px);">
|
||||
<div
|
||||
style="height:100vh;border-left :1px solid #d6d7d8; float: initial; display: flow-root!important;"
|
||||
data-bind="
|
||||
attr: {
|
||||
id: documentEditorId
|
||||
},
|
||||
css: {
|
||||
mongoDocumentEditor:$root.isPreferredApiMongoDB()
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Editor - End -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ids and Editor - End -->
|
||||
</div>
|
||||
331
src/Explorer/Tabs/MongoDocumentsTab.ts
Normal file
331
src/Explorer/Tabs/MongoDocumentsTab.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import DocumentId from "../Tree/DocumentId";
|
||||
import DocumentsTab from "./DocumentsTab";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import MongoUtility from "../../Common/MongoUtility";
|
||||
import ObjectId from "../Tree/ObjectId";
|
||||
import Q from "q";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import {
|
||||
createDocument,
|
||||
deleteDocument,
|
||||
queryDocuments,
|
||||
readDocument,
|
||||
updateDocument
|
||||
} from "../../Common/MongoProxyClient";
|
||||
import { extractPartitionKey } from "@azure/cosmos";
|
||||
import { Logger } from "../../Common/Logger";
|
||||
import { PartitionKeyDefinition } from "@azure/cosmos";
|
||||
|
||||
export default class MongoDocumentsTab extends DocumentsTab implements ViewModels.DocumentsTab {
|
||||
public collection: ViewModels.Collection;
|
||||
private continuationToken: string;
|
||||
|
||||
constructor(options: ViewModels.DocumentsTabOptions) {
|
||||
super(options);
|
||||
this.lastFilterContents = ko.observableArray<string>(['{"id":"foo"}', "{ qty: { $gte: 20 } }"]);
|
||||
|
||||
if (this.partitionKeyProperty && ~this.partitionKeyProperty.indexOf(`"`)) {
|
||||
this.partitionKeyProperty = this.partitionKeyProperty.replace(/["]+/g, "");
|
||||
}
|
||||
|
||||
if (this.partitionKeyProperty && this.partitionKeyProperty.indexOf("$v") > -1) {
|
||||
// From $v.shard.$v.key.$v > shard.key
|
||||
this.partitionKeyProperty = this.partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, "");
|
||||
this.partitionKeyPropertyHeader = "/" + this.partitionKeyProperty;
|
||||
}
|
||||
|
||||
this.isFilterExpanded = ko.observable<boolean>(true);
|
||||
super.buildCommandBarOptions.bind(this);
|
||||
super.buildCommandBarOptions();
|
||||
}
|
||||
|
||||
public onSaveNewDocumentClick = (): Q.Promise<any> => {
|
||||
const documentContent = JSON.parse(this.selectedDocumentContent());
|
||||
this.displayedError("");
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
|
||||
if (
|
||||
this.partitionKeyProperty &&
|
||||
this.partitionKeyProperty !== "_id" &&
|
||||
!this._hasShardKeySpecified(documentContent)
|
||||
) {
|
||||
const message = `The document is lacking the shard property: ${this.partitionKeyProperty}`;
|
||||
this.displayedError(message);
|
||||
let that = this;
|
||||
setTimeout(() => {
|
||||
that.displayedError("");
|
||||
}, Constants.ClientDefaults.errorNotificationTimeoutMs);
|
||||
this.isExecutionError(true);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
Logger.logError("Failed to save new document: Document shard key not defined", "MongoDocumentsTab");
|
||||
return Q.reject("Document without shard key");
|
||||
}
|
||||
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
return Q(createDocument(this.collection.databaseId, this.collection, this.partitionKeyProperty, documentContent))
|
||||
.then(
|
||||
(savedDocument: any) => {
|
||||
let partitionKeyArray = extractPartitionKey(
|
||||
savedDocument,
|
||||
this._getPartitionKeyDefinition() as PartitionKeyDefinition
|
||||
);
|
||||
|
||||
let partitionKeyValue = partitionKeyArray && partitionKeyArray[0];
|
||||
|
||||
let id = new ObjectId(this, savedDocument, partitionKeyValue);
|
||||
let ids = this.documentIds();
|
||||
ids.push(id);
|
||||
delete savedDocument._self;
|
||||
|
||||
let value: string = this.renderObjectForEditor(savedDocument || {}, null, 4);
|
||||
this.selectedDocumentContent.setBaseline(value);
|
||||
|
||||
this.selectedDocumentId(id);
|
||||
this.documentIds(ids);
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CreateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
reason => {
|
||||
this.isExecutionError(true);
|
||||
const message = ErrorParserUtility.parse(reason)[0].message;
|
||||
window.alert(message);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public onSaveExisitingDocumentClick = (): Q.Promise<any> => {
|
||||
const selectedDocumentId = this.selectedDocumentId();
|
||||
const documentContent = this.selectedDocumentContent();
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.UpdateDocument, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
|
||||
return Q(updateDocument(this.collection.databaseId, this.collection, selectedDocumentId, documentContent))
|
||||
.then(
|
||||
(updatedDocument: any) => {
|
||||
let value: string = this.renderObjectForEditor(updatedDocument || {}, null, 4);
|
||||
this.selectedDocumentContent.setBaseline(value);
|
||||
|
||||
this.documentIds().forEach((documentId: ViewModels.DocumentId) => {
|
||||
if (documentId.rid === updatedDocument._rid) {
|
||||
const partitionKeyArray = extractPartitionKey(
|
||||
updatedDocument,
|
||||
this._getPartitionKeyDefinition() as PartitionKeyDefinition
|
||||
);
|
||||
|
||||
let partitionKeyValue = partitionKeyArray && partitionKeyArray[0];
|
||||
|
||||
const id = new ObjectId(this, updatedDocument, partitionKeyValue);
|
||||
documentId.id(id.id());
|
||||
}
|
||||
});
|
||||
this.editorState(ViewModels.DocumentExplorerState.exisitingDocumentNoEdits);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
reason => {
|
||||
const message = ErrorParserUtility.parse(reason)[0].message;
|
||||
window.alert(message);
|
||||
this.isExecutionError(true);
|
||||
console.error(reason);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateDocument,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public buildQuery(filter: string): string {
|
||||
return filter || "{}";
|
||||
}
|
||||
|
||||
public selectDocument(documentId: ViewModels.DocumentId): Q.Promise<any> {
|
||||
this.selectedDocumentId(documentId);
|
||||
return Q(
|
||||
readDocument(this.collection.databaseId, this.collection, documentId).then((content: any) => {
|
||||
this.initDocumentEditor(documentId, content);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public loadNextPage(): Q.Promise<any> {
|
||||
this.isExecuting(true);
|
||||
this.isExecutionError(false);
|
||||
const filter: string = this.filterContent().trim();
|
||||
const query: string = this.buildQuery(filter);
|
||||
|
||||
return Q(queryDocuments(this.collection.databaseId, this.collection, true, query, this.continuationToken))
|
||||
.then(
|
||||
({ continuationToken, documents }) => {
|
||||
this.continuationToken = continuationToken;
|
||||
let currentDocuments = this.documentIds();
|
||||
const currentDocumentsRids = currentDocuments.map(currentDocument => currentDocument.rid);
|
||||
const nextDocumentIds = documents
|
||||
.filter((d: any) => {
|
||||
return currentDocumentsRids.indexOf(d._rid) < 0;
|
||||
})
|
||||
.map((rawDocument: any) => {
|
||||
const partitionKeyValue = rawDocument._partitionKeyValue;
|
||||
return <ViewModels.DocumentId>new DocumentId(this, rawDocument, partitionKeyValue);
|
||||
});
|
||||
|
||||
const merged = currentDocuments.concat(nextDocumentIds);
|
||||
|
||||
this.documentIds(merged);
|
||||
currentDocuments = this.documentIds();
|
||||
if (this.filterContent().length > 0 && currentDocuments.length > 0) {
|
||||
currentDocuments[0].click();
|
||||
} else {
|
||||
this.selectedDocumentContent("");
|
||||
this.selectedDocumentId(null);
|
||||
this.editorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
}
|
||||
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
},
|
||||
(error: any) => {
|
||||
if (this.onLoadStartKey != null && this.onLoadStartKey != undefined) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle(),
|
||||
error: error
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
this.onLoadStartKey = null;
|
||||
}
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
}
|
||||
|
||||
protected _onEditorContentChange(newContent: string) {
|
||||
try {
|
||||
if (
|
||||
this.editorState() === ViewModels.DocumentExplorerState.newDocumentValid ||
|
||||
this.editorState() === ViewModels.DocumentExplorerState.newDocumentInvalid
|
||||
) {
|
||||
let parsed: any = JSON.parse(newContent);
|
||||
}
|
||||
|
||||
// Mongo uses BSON format for _id, trying to parse it as JSON blocks normal flow in an edit
|
||||
this.onValidDocumentEdit();
|
||||
} catch (e) {
|
||||
this.onInvalidDocumentEdit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a Javascript object to be displayed inside Monaco Editor */
|
||||
protected renderObjectForEditor(value: any, replacer: any, space: string | number): string {
|
||||
return MongoUtility.tojson(value, null, false);
|
||||
}
|
||||
|
||||
private _hasShardKeySpecified(document: any): boolean {
|
||||
return Boolean(extractPartitionKey(document, this._getPartitionKeyDefinition() as PartitionKeyDefinition));
|
||||
}
|
||||
|
||||
private _getPartitionKeyDefinition(): DataModels.PartitionKey {
|
||||
let partitionKey: DataModels.PartitionKey = this.partitionKey;
|
||||
|
||||
if (
|
||||
this.partitionKey &&
|
||||
this.partitionKey.paths &&
|
||||
this.partitionKey.paths.length &&
|
||||
this.partitionKey.paths.length > 0 &&
|
||||
this.partitionKey.paths[0].indexOf("$v") > -1
|
||||
) {
|
||||
// Convert BsonSchema2 to /path format
|
||||
partitionKey = {
|
||||
kind: partitionKey.kind,
|
||||
paths: ["/" + this.partitionKeyProperty.replace(/\./g, "/")],
|
||||
version: partitionKey.version
|
||||
};
|
||||
}
|
||||
|
||||
return partitionKey;
|
||||
}
|
||||
|
||||
protected __deleteDocument(documentId: ViewModels.DocumentId): Q.Promise<any> {
|
||||
return Q(deleteDocument(this.collection.databaseId, this.collection, documentId));
|
||||
}
|
||||
}
|
||||
120
src/Explorer/Tabs/MongoQueryTab.html
Normal file
120
src/Explorer/Tabs/MongoQueryTab.html
Normal file
@@ -0,0 +1,120 @@
|
||||
<div
|
||||
class="tab-pane"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: tabId
|
||||
}"
|
||||
role="tabpanel"
|
||||
>
|
||||
<!-- Query Tab Command Bar - Start -->
|
||||
<div class="contentdiv">
|
||||
<div class="tabCommandButton">
|
||||
<!-- Execute Query - Start -->
|
||||
<span
|
||||
class="commandButton"
|
||||
data-bind="
|
||||
click: onExecuteQueryClick,
|
||||
visible: executeQueryButton.visible() && executeQueryButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/ExecuteQuery.svg" />Run
|
||||
</span>
|
||||
<span
|
||||
class="commandButton tabCommandDisabled"
|
||||
data-bind="
|
||||
visible: executeQueryButton.visible() && !executeQueryButton.enabled()"
|
||||
>
|
||||
<img class="imgiconwidth" src="/ExecuteQuery-disabled.svg" />Run
|
||||
</span>
|
||||
<!-- Execute Query - End -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Query Tab Command Bar - End -->
|
||||
|
||||
<div
|
||||
class="queryEditor"
|
||||
data-bind="
|
||||
attr: {
|
||||
id: queryEditorId
|
||||
},
|
||||
css: {
|
||||
mongoQueryEditor:$root.isPreferredApiMongoDB()
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
style="margin-left:50px; margin-top:-75px;"
|
||||
data-bind="
|
||||
visible: $root.isPreferredApiMongoDB() && sqlQueryEditorContent().length == 0"
|
||||
>
|
||||
Start by writing a Mongo query, for example: <strong>{'id':'foo'}</strong> or <strong>{ }</strong> to get all the
|
||||
documents.
|
||||
</div>
|
||||
<!-- Query Errors Tab - Start-->
|
||||
<div class="active queryErrorsHeaderContainer" data-bind="visible: errors().length > 0">
|
||||
<span
|
||||
class="queryErrors"
|
||||
data-toggle="tab"
|
||||
data-bind="
|
||||
attr: {
|
||||
href: '#queryerrors' + tabId
|
||||
}"
|
||||
>Errors</span
|
||||
>
|
||||
</div>
|
||||
<!-- Query Errors Tab - End -->
|
||||
|
||||
<!-- Query Results & Errors Content Container - Start-->
|
||||
<div class="queryResultErrorContentContainer">
|
||||
<!-- Query Results Content - Start-->
|
||||
<div
|
||||
class="tab-pane active"
|
||||
data-bind="
|
||||
id: {
|
||||
href: 'queryresults' + tabId
|
||||
},
|
||||
visible: allResultsMetadata().length > 0 && !errors().length > 0"
|
||||
>
|
||||
<div class="queryResultsValue">
|
||||
<span class="queryResults"> Results: </span> <span data-bind="text: showingDocumentsDisplayText"></span>
|
||||
<span class="queryResultDivider"> | </span> <span> Request Charge: </span>
|
||||
<span data-bind="text: requestChargeDisplayText"></span> <span class="queryResultDivider"> | </span>
|
||||
<span class="queryResultNextEnable" data-bind="visible: fetchNextPageButton.enabled">
|
||||
<a
|
||||
data-bind="
|
||||
click: onFetchNextPageClick"
|
||||
>
|
||||
<span>Next</span> <img class="queryResultnextImg" src="/Query-Editor-Next.svg" />
|
||||
</a>
|
||||
</span>
|
||||
<span class="queryResultNextDisable" data-bind="visible: !fetchNextPageButton.enabled()">
|
||||
<span>Next</span> <img class="queryResultnextImg" src="/Query-Editor-Next-Disabled.svg" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style="height: 600px;"
|
||||
data-bind="
|
||||
attr: {
|
||||
id: resultsEditorId
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Query Results Content - Start-->
|
||||
|
||||
<!-- Query Errors Content - Start-->
|
||||
<div
|
||||
class="tab-pane active"
|
||||
data-bind="
|
||||
id: {
|
||||
href: 'queryerrors' + tabId
|
||||
},
|
||||
visible: errors().length > 0"
|
||||
>
|
||||
<!-- ko foreach: errors -->
|
||||
<div style="margin-left:17px; font-size: 12px;">
|
||||
<span data-bind="text: $data.code"></span> : <span data-bind="text: $data.message"></span>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
<!-- Query Errors Content - End-->
|
||||
</div>
|
||||
<!-- Results & Errors Content Container - Endt-->
|
||||
</div>
|
||||
29
src/Explorer/Tabs/MongoQueryTab.ts
Normal file
29
src/Explorer/Tabs/MongoQueryTab.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import Q from "q";
|
||||
import MongoUtility from "../../Common/MongoUtility";
|
||||
import QueryTab from "./QueryTab";
|
||||
import * as HeadersUtility from "../../Common/HeadersUtility";
|
||||
import { queryIterator } from "../../Common/MongoProxyClient";
|
||||
import { MinimalQueryIterator } from "../../Common/IteratorUtilities";
|
||||
|
||||
export default class MongoQueryTab extends QueryTab implements ViewModels.QueryTab {
|
||||
public collection: ViewModels.Collection;
|
||||
|
||||
constructor(options: ViewModels.QueryTabOptions) {
|
||||
options.queryText = ""; // override sql query editor content for now so we only display mongo related help items
|
||||
super(options);
|
||||
this.isPreferredApiMongoDB = true;
|
||||
this.monacoSettings = new ViewModels.MonacoEditorSettings("plaintext", false);
|
||||
}
|
||||
/** Renders a Javascript object to be displayed inside Monaco Editor */
|
||||
protected renderObjectForEditor(value: any, replacer: any, space: string | number): string {
|
||||
return MongoUtility.tojson(value, null, false);
|
||||
}
|
||||
|
||||
protected _initIterator(): Q.Promise<MinimalQueryIterator> {
|
||||
let options: any = {};
|
||||
options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey();
|
||||
this._iterator = queryIterator(this.collection.databaseId, this.collection, this.sqlStatementToExecute());
|
||||
return Q(this._iterator);
|
||||
}
|
||||
}
|
||||
15
src/Explorer/Tabs/MongoShellTab.html
Normal file
15
src/Explorer/Tabs/MongoShellTab.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<iframe
|
||||
name="explorer"
|
||||
class="iframe"
|
||||
style="width:100%;height:100%;border:0px;padding:0px;margin:0px;overflow:hidden"
|
||||
data-bind="
|
||||
attr: {
|
||||
src: url,
|
||||
id: tabId
|
||||
},
|
||||
event:{
|
||||
load: setContentFocus(event)
|
||||
}"
|
||||
title="Mongo Shell"
|
||||
role="tabpanel"
|
||||
></iframe>
|
||||
221
src/Explorer/Tabs/MongoShellTab.ts
Normal file
221
src/Explorer/Tabs/MongoShellTab.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import AuthHeadersUtil from "../../Platform/Hosted/Authorization";
|
||||
import EnvironmentUtility from "../../Common/EnvironmentUtility";
|
||||
import { isInvalidParentFrameOrigin } from "../../Utils/MessageValidation";
|
||||
import Q from "q";
|
||||
import TabsBase from "./TabsBase";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { CosmosClient } from "../../Common/CosmosClient";
|
||||
import { HashMap } from "../../Common/HashMap";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import { PlatformType } from "../../PlatformType";
|
||||
|
||||
export default class MongoShellTab extends TabsBase implements ViewModels.MongoShellTab {
|
||||
public url: ko.Computed<string>;
|
||||
private _container: ViewModels.Explorer;
|
||||
private _runtimeEndpoint: string;
|
||||
private _logTraces: HashMap<number>;
|
||||
|
||||
constructor(options: ViewModels.TabOptions) {
|
||||
super(options);
|
||||
this._logTraces = new HashMap<number>();
|
||||
this._container = options.collection.container;
|
||||
this.url = ko.computed<string>(() => {
|
||||
const account: ViewModels.DatabaseAccount = CosmosClient.databaseAccount();
|
||||
const resourceId: string = account && account.id;
|
||||
const accountName = account && account.name;
|
||||
const mongoEndpoint = account && (account.properties.mongoEndpoint || account.properties.documentEndpoint);
|
||||
|
||||
this._runtimeEndpoint =
|
||||
window.dataExplorerPlatform == PlatformType.Hosted ? AuthHeadersUtil.extensionEndpoint : "";
|
||||
const extensionEndpoint: string = this._container.extensionEndpoint() || this._runtimeEndpoint || "";
|
||||
let baseUrl = "/content/mongoshell/dist/";
|
||||
if (this._container.serverId() === "localhost") {
|
||||
baseUrl = "/content/mongoshell/";
|
||||
}
|
||||
|
||||
return `${extensionEndpoint}${baseUrl}index.html?resourceId=${resourceId}&accountName=${accountName}&mongoEndpoint=${mongoEndpoint}`;
|
||||
});
|
||||
|
||||
window.addEventListener("message", this.handleMessage.bind(this), false);
|
||||
}
|
||||
|
||||
public setContentFocus(event: any): any {
|
||||
// TODO: Work around cross origin security issue in Hosted Data Explorer by using Shell <-> Data Explorer messaging (253527)
|
||||
// if(event.type === "load" && window.dataExplorerPlatform != PlatformType.Hosted) {
|
||||
// let activeShell = event.target.contentWindow && event.target.contentWindow.mongo && event.target.contentWindow.mongo.shells && event.target.contentWindow.mongo.shells[0];
|
||||
// activeShell && setTimeout(function(){
|
||||
// activeShell.focus();
|
||||
// },2000);
|
||||
// }
|
||||
}
|
||||
|
||||
public onTabClick(): Q.Promise<any> {
|
||||
return super.onTabClick().then(() => {
|
||||
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents);
|
||||
});
|
||||
}
|
||||
|
||||
public handleMessage(event: MessageEvent) {
|
||||
if (isInvalidParentFrameOrigin(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shellIframe: HTMLIFrameElement = <HTMLIFrameElement>document.getElementById(this.tabId);
|
||||
|
||||
if (!shellIframe) {
|
||||
return;
|
||||
}
|
||||
if (typeof event.data !== "object" || event.data["signature"] !== "mongoshell") {
|
||||
return;
|
||||
}
|
||||
if (!("data" in event.data) || !("eventType" in event.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.eventType == MessageType.IframeReady) {
|
||||
this.handleReadyMessage(event, shellIframe);
|
||||
} else if (event.data.eventType == MessageType.Notification) {
|
||||
this.handleNotificationMessage(event, shellIframe);
|
||||
} else {
|
||||
this.handleLogMessage(event, shellIframe);
|
||||
}
|
||||
}
|
||||
|
||||
private handleReadyMessage(event: MessageEvent, shellIframe: HTMLIFrameElement) {
|
||||
if (typeof event.data["data"] !== "string") {
|
||||
return;
|
||||
}
|
||||
if (event.data.data !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
const authorization: string = CosmosClient.authorizationToken() || "";
|
||||
const resourceId = this._container.databaseAccount().id;
|
||||
const accountName = this._container.databaseAccount().name;
|
||||
const documentEndpoint =
|
||||
this._container.databaseAccount().properties.mongoEndpoint ||
|
||||
this._container.databaseAccount().properties.documentEndpoint;
|
||||
const mongoEndpoint =
|
||||
documentEndpoint.substr(
|
||||
Constants.MongoDBAccounts.protocol.length + 3,
|
||||
documentEndpoint.length -
|
||||
(Constants.MongoDBAccounts.protocol.length + 2 + Constants.MongoDBAccounts.defaultPort.length)
|
||||
) + Constants.MongoDBAccounts.defaultPort.toString();
|
||||
const databaseId = this.collection.databaseId;
|
||||
const collectionId = this.collection.id();
|
||||
const apiEndpoint = EnvironmentUtility.getMongoBackendEndpoint(
|
||||
this._container.serverId(),
|
||||
CosmosClient.databaseAccount().location,
|
||||
this._container.extensionEndpoint()
|
||||
).replace("/api/mongo/explorer", "");
|
||||
const encryptedAuthToken: string = CosmosClient.accessToken();
|
||||
|
||||
shellIframe.contentWindow.postMessage(
|
||||
{
|
||||
signature: "dataexplorer",
|
||||
data: {
|
||||
resourceId: resourceId,
|
||||
accountName: accountName,
|
||||
mongoEndpoint: mongoEndpoint,
|
||||
authorization: authorization,
|
||||
databaseId: databaseId,
|
||||
collectionId: collectionId,
|
||||
encryptedAuthToken: encryptedAuthToken,
|
||||
apiEndpoint: apiEndpoint
|
||||
}
|
||||
},
|
||||
this._container.extensionEndpoint()
|
||||
);
|
||||
}
|
||||
|
||||
private handleLogMessage(event: MessageEvent, shellIframe: HTMLIFrameElement) {
|
||||
if (!("logType" in event.data.data) || typeof event.data.data["logType"] !== "string") {
|
||||
return;
|
||||
}
|
||||
if (!("logData" in event.data.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToLog: string = event.data.data.logData;
|
||||
const logType: string = event.data.data.logType;
|
||||
const shellTraceId: string = event.data.data.traceId || "none";
|
||||
|
||||
switch (logType) {
|
||||
case LogType.Information:
|
||||
TelemetryProcessor.trace(Action.MongoShell, ActionModifiers.Success, dataToLog);
|
||||
break;
|
||||
case LogType.Warning:
|
||||
TelemetryProcessor.trace(Action.MongoShell, ActionModifiers.Failed, dataToLog);
|
||||
break;
|
||||
case LogType.Verbose:
|
||||
TelemetryProcessor.trace(Action.MongoShell, ActionModifiers.Mark, dataToLog);
|
||||
break;
|
||||
case LogType.StartTrace:
|
||||
const telemetryTraceId: number = TelemetryProcessor.traceStart(Action.MongoShell, dataToLog);
|
||||
this._logTraces.set(shellTraceId, telemetryTraceId);
|
||||
break;
|
||||
case LogType.SuccessTrace:
|
||||
if (this._logTraces.has(shellTraceId)) {
|
||||
const originalTelemetryTraceId: number = this._logTraces.get(shellTraceId);
|
||||
TelemetryProcessor.traceSuccess(Action.MongoShell, dataToLog, originalTelemetryTraceId);
|
||||
this._logTraces.delete(shellTraceId);
|
||||
} else {
|
||||
TelemetryProcessor.trace(Action.MongoShell, ActionModifiers.Success, dataToLog);
|
||||
}
|
||||
break;
|
||||
case LogType.FailureTrace:
|
||||
if (this._logTraces.has(shellTraceId)) {
|
||||
const originalTelemetryTraceId: number = this._logTraces.get(shellTraceId);
|
||||
TelemetryProcessor.traceFailure(Action.MongoShell, dataToLog, originalTelemetryTraceId);
|
||||
this._logTraces.delete(shellTraceId);
|
||||
} else {
|
||||
TelemetryProcessor.trace(Action.MongoShell, ActionModifiers.Failed, dataToLog);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotificationMessage(event: MessageEvent, shellIframe: HTMLIFrameElement) {
|
||||
if (!("logType" in event.data.data) || typeof event.data.data["logType"] !== "string") {
|
||||
return;
|
||||
}
|
||||
if (!("logData" in event.data.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToLog: string = event.data.data.logData;
|
||||
const logType: string = event.data.data.logType;
|
||||
|
||||
switch (logType) {
|
||||
case LogType.Information:
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, dataToLog);
|
||||
break;
|
||||
case LogType.Warning:
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, dataToLog);
|
||||
break;
|
||||
case LogType.InProgress:
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, dataToLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MessageType {
|
||||
static IframeReady: string = "iframeready";
|
||||
static Notification: string = "notification";
|
||||
static Log: string = "log";
|
||||
}
|
||||
|
||||
class LogType {
|
||||
static Information: string = "information";
|
||||
static Warning: string = "warning";
|
||||
static Verbose: string = "verbose";
|
||||
static InProgress: string = "inprogress";
|
||||
static StartTrace: string = "start";
|
||||
static SuccessTrace: string = "success";
|
||||
static FailureTrace: string = "failure";
|
||||
}
|
||||
7
src/Explorer/Tabs/NotebookTab.html
Normal file
7
src/Explorer/Tabs/NotebookTab.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div style="width: 100%; height: 100%; margin-left: 3px;" data-bind="attr: { id: tabId }">
|
||||
<!-- This runs the NotebookApp hosted by DataExplorer -->
|
||||
<iframe
|
||||
style="width:100%; height: 100%; border:none"
|
||||
data-bind="setTemplateReady: true, attr: { id: notebookContainerId, src: notebookAppIFrameSrc }"
|
||||
></iframe>
|
||||
</div>
|
||||
539
src/Explorer/Tabs/NotebookTab.ts
Normal file
539
src/Explorer/Tabs/NotebookTab.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import * as Q from "q";
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
|
||||
import NewCellIcon from "../../../images/notebook/Notebook-insert-cell.svg";
|
||||
import CutIcon from "../../../images/notebook/Notebook-cut.svg";
|
||||
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
|
||||
import PasteIcon from "../../../images/notebook/Notebook-paste.svg";
|
||||
import RunIcon from "../../../images/notebook/Notebook-run.svg";
|
||||
import RunAllIcon from "../../../images/notebook/Notebook-run-all.svg";
|
||||
import RestartIcon from "../../../images/notebook/Notebook-restart.svg";
|
||||
import SaveIcon from "../../../images/save-cosmos.svg";
|
||||
import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-outputs.svg";
|
||||
import UndoIcon from "../../../images/notebook/Notebook-undo.svg";
|
||||
import RedoIcon from "../../../images/notebook/Notebook-redo.svg";
|
||||
import { CommandBarComponentButtonFactory } from "../Menus/CommandBar/CommandBarComponentButtonFactory";
|
||||
import { NotebookAppMessageHandler } from "../Controls/Notebook/NotebookAppMessageHandler";
|
||||
import * as NotebookAppContracts from "../../Terminal/NotebookAppContracts";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { isInvalidParentFrameOrigin } from "../../Utils/MessageValidation";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import { NotebookTerminalComponent } from "../Controls/Notebook/NotebookTerminalComponent";
|
||||
import { NotebookConfigurationUtils } from "../../Utils/NotebookConfigurationUtils";
|
||||
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
|
||||
|
||||
interface Kernel {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export default class NotebookTab extends TabsBase implements ViewModels.Tab {
|
||||
private notebookAppIFrameSrc: ko.Computed<string>;
|
||||
private container: ViewModels.Explorer;
|
||||
public notebookPath: ko.Observable<string>;
|
||||
private messageListener: (ev: MessageEvent) => any;
|
||||
private activeCellTypeStr: string;
|
||||
private notebookContainerId: string;
|
||||
private currentKernelName: string;
|
||||
private availableKernels: Kernel[];
|
||||
private messageHandler: NotebookAppMessageHandler;
|
||||
private notificationProgressId: string;
|
||||
private isSwitchingKernels: ko.Observable<boolean>;
|
||||
|
||||
constructor(options: ViewModels.NotebookTabOptions) {
|
||||
super(options);
|
||||
this.availableKernels = [];
|
||||
this.isSwitchingKernels = ko.observable<boolean>(false);
|
||||
this.messageListener = async (ev: MessageEvent) => {
|
||||
if (isInvalidParentFrameOrigin(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg: NotebookAppContracts.FromNotebookMessage = ev.data;
|
||||
|
||||
if (msg.actionType === NotebookAppContracts.ActionTypes.Response) {
|
||||
this.messageHandler.handleCachedDataMessage(msg);
|
||||
} else if (msg.actionType === NotebookAppContracts.ActionTypes.Update) {
|
||||
const updateMessage = msg.message as NotebookAppContracts.FromNotebookUpdateMessage;
|
||||
switch (updateMessage.type) {
|
||||
case NotebookAppContracts.NotebookUpdateTypes.ActiveCellType:
|
||||
this.activeCellTypeStr = updateMessage.arg;
|
||||
this.updateNavbarWithTabsButtons();
|
||||
break;
|
||||
case NotebookAppContracts.NotebookUpdateTypes.KernelChange:
|
||||
this.isSwitchingKernels(false);
|
||||
this.currentKernelName = updateMessage.arg;
|
||||
this.messageHandler
|
||||
.sendCachedDataMessage<NotebookAppContracts.KernelSpecs>(NotebookAppContracts.MessageTypes.KernelList)
|
||||
.then(specs => {
|
||||
this.availableKernels = Object.keys(specs.kernelSpecs)
|
||||
.map((name: string) => ({ name: name, displayName: specs.kernelSpecs[name].displayName }))
|
||||
.sort((a: NotebookAppContracts.KernelOption, b: NotebookAppContracts.KernelOption) => {
|
||||
// Put default at the top, otherwise lexicographically compare
|
||||
if (a.name === specs.defaultName) {
|
||||
return -1;
|
||||
} else if (b.name === specs.defaultName) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
}
|
||||
});
|
||||
this.updateNavbarWithTabsButtons();
|
||||
});
|
||||
|
||||
this.updateNavbarWithTabsButtons();
|
||||
await this.configureServiceEndpoints(this.currentKernelName);
|
||||
break;
|
||||
case NotebookAppContracts.NotebookUpdateTypes.ClickEvent:
|
||||
this.simulateClick();
|
||||
break;
|
||||
case NotebookAppContracts.NotebookUpdateTypes.SessionStatusChange: {
|
||||
this.handleSessionStateChange(updateMessage.arg as NotebookAppContracts.KernelStatusStates);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error("Unknown command", updateMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
super.onTemplateReady((isTemplateReady: boolean) => {
|
||||
if (isTemplateReady) {
|
||||
window.addEventListener("message", this.messageListener, false);
|
||||
|
||||
const iFrame: HTMLIFrameElement = document.getElementById(this.notebookContainerId) as HTMLIFrameElement;
|
||||
this.messageHandler = new NotebookAppMessageHandler(iFrame.contentWindow);
|
||||
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Status);
|
||||
}
|
||||
});
|
||||
this.container = options.container;
|
||||
|
||||
this.notebookAppIFrameSrc = ko.computed<string>(() =>
|
||||
NotebookTerminalComponent.createNotebookAppSrc(
|
||||
this.container.notebookServerInfo(),
|
||||
new Map<string, string>([["notebookpath", options.notebookContentItem.path]])
|
||||
)
|
||||
);
|
||||
this.notebookPath = ko.observable(options.notebookContentItem.path);
|
||||
|
||||
this.notebookContainerId = `notebookContainer-${this.tabId}`;
|
||||
|
||||
this.container.notebookServerInfo.subscribe((newValue: DataModels.NotebookWorkspaceConnectionInfo) => {
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "New notebook server info received.");
|
||||
});
|
||||
|
||||
this.container &&
|
||||
this.container.arcadiaToken.subscribe(async () => {
|
||||
const currentKernel = this.currentKernelName;
|
||||
if (!currentKernel) {
|
||||
return;
|
||||
}
|
||||
await this.configureServiceEndpoints(currentKernel);
|
||||
});
|
||||
}
|
||||
|
||||
public onCloseTabButtonClick(): Q.Promise<any> {
|
||||
const cleanup = () => {
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Shutdown);
|
||||
window.removeEventListener("message", this.messageListener);
|
||||
this.isActive(false);
|
||||
super.onCloseTabButtonClick();
|
||||
};
|
||||
|
||||
return this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.IsDirty).then((isDirty: boolean) => {
|
||||
if (isDirty) {
|
||||
this.container.showOkCancelModalDialog(
|
||||
"Close without saving?",
|
||||
`File has unsaved changes, close without saving?`,
|
||||
"Close",
|
||||
cleanup,
|
||||
"Cancel",
|
||||
undefined
|
||||
);
|
||||
return Q.resolve(null);
|
||||
} else {
|
||||
cleanup();
|
||||
return Q.resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onActivate(): Q.Promise<any> {
|
||||
if (this.messageHandler) {
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Status);
|
||||
}
|
||||
return super.onActivate();
|
||||
}
|
||||
|
||||
public async reconfigureServiceEndpoints() {
|
||||
return await this.configureServiceEndpoints(this.currentKernelName);
|
||||
}
|
||||
|
||||
private handleSessionStateChange(state: NotebookAppContracts.KernelStatusStates) {
|
||||
switch (state) {
|
||||
case "reconnecting":
|
||||
this.clearProgressNotification();
|
||||
this.notificationProgressId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
"Connection with Notebook Server lost. Reconnecting..."
|
||||
);
|
||||
break;
|
||||
case "dead":
|
||||
// This happens when the jupyter server detects that the kernel to which the cell was connected is no longer alive.
|
||||
// It can be caused by the jupyter server going down and back up again and informing the client that the kernel to which
|
||||
// it was previously connected to doesn't exist anymore. Send a restart kernel command.
|
||||
if (!this.isSwitchingKernels()) {
|
||||
this.notificationProgressId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
"Connection with Notebook Server dead. Trying to reconnect..."
|
||||
);
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.RestartKernel);
|
||||
}
|
||||
break;
|
||||
case "connected":
|
||||
this.clearProgressNotification();
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
"Connection with Notebook Server established."
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.clearProgressNotification();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private clearProgressNotification() {
|
||||
if (this.notificationProgressId) {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(this.notificationProgressId);
|
||||
this.notificationProgressId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private static isUntitledNotebook(notebookFile: NotebookContentItem): boolean {
|
||||
return notebookFile.name.indexOf("Untitled") === 0;
|
||||
}
|
||||
|
||||
protected getContainer(): ViewModels.Explorer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const saveLabel = "Save";
|
||||
const workspaceLabel = "Workspace";
|
||||
const kernelLabel = "Kernel";
|
||||
const runLabel = "Run";
|
||||
const runActiveCellLabel = "Run Active Cell";
|
||||
const runAllLabel = "Run All";
|
||||
const restartKernelLabel = "Restart Kernel";
|
||||
const clearLabel = "Clear outputs";
|
||||
const newCellLabel = "New Cell";
|
||||
const cellTypeLabel = "Cell Type";
|
||||
const codeLabel = "Code";
|
||||
const markdownLabel = "Markdown";
|
||||
const rawLabel = "Raw";
|
||||
const copyLabel = "Copy";
|
||||
const cutLabel = "Cut";
|
||||
const pasteLabel = "Paste";
|
||||
const undoLabel = "Undo";
|
||||
const redoLabel = "Redo";
|
||||
const cellCodeType = "code";
|
||||
const cellMarkdownType = "markdown";
|
||||
const cellRawType = "raw";
|
||||
let buttons: ViewModels.NavbarButtonConfig[] = [
|
||||
{
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: saveLabel,
|
||||
onCommandClick: () =>
|
||||
this.sendMessageToNotebook(
|
||||
NotebookAppContracts.MessageTypes.Save
|
||||
).then((result: NotebookAppContracts.ContentItem) =>
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `File "${result.name}" was saved.`)
|
||||
),
|
||||
commandButtonLabel: saveLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: saveLabel
|
||||
},
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: kernelLabel,
|
||||
onCommandClick: () => {},
|
||||
commandButtonLabel: null,
|
||||
hasPopup: false,
|
||||
disabled: this.availableKernels.length < 1,
|
||||
isDropdown: true,
|
||||
dropdownPlaceholder: kernelLabel,
|
||||
dropdownSelectedKey: this.currentKernelName,
|
||||
dropdownWidth: 100,
|
||||
children: this.availableKernels.map((kernel: { name: string; displayName: string }) => ({
|
||||
iconSrc: null,
|
||||
iconAlt: kernel.displayName,
|
||||
onCommandClick: () => {
|
||||
this.isSwitchingKernels(true);
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.ChangeKernel, kernel.name);
|
||||
},
|
||||
commandButtonLabel: kernel.displayName,
|
||||
dropdownItemKey: kernel.name,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: kernel.displayName
|
||||
})),
|
||||
ariaLabel: kernelLabel
|
||||
},
|
||||
{
|
||||
iconSrc: RunIcon,
|
||||
iconAlt: runLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.RunAndAdvance),
|
||||
commandButtonLabel: runLabel,
|
||||
ariaLabel: runLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
iconSrc: RunIcon,
|
||||
iconAlt: runActiveCellLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.RunAndAdvance),
|
||||
commandButtonLabel: runActiveCellLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: runActiveCellLabel
|
||||
},
|
||||
{
|
||||
iconSrc: RunAllIcon,
|
||||
iconAlt: runAllLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.RunAll),
|
||||
commandButtonLabel: runAllLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: runAllLabel
|
||||
},
|
||||
// {
|
||||
// iconSrc: null,
|
||||
// onCommandClick: () => this.postMessage("switchKernel"),
|
||||
// commandButtonLabel: "Switch Kernel",
|
||||
// hasPopup: false,
|
||||
// disabled: false
|
||||
// },
|
||||
{
|
||||
iconSrc: RestartIcon,
|
||||
iconAlt: restartKernelLabel,
|
||||
onCommandClick: () =>
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.RestartKernel).then(
|
||||
(isSuccessful: boolean) => {
|
||||
// Note: don't handle isSuccessful === false as it gets triggered if user cancels kernel restart modal dialog
|
||||
if (isSuccessful) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
"Kernel was successfully restarted"
|
||||
);
|
||||
}
|
||||
}
|
||||
),
|
||||
commandButtonLabel: restartKernelLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: restartKernelLabel
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
iconSrc: ClearAllOutputsIcon,
|
||||
iconAlt: clearLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.ClearAllOutputs),
|
||||
commandButtonLabel: clearLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: clearLabel
|
||||
},
|
||||
{
|
||||
iconSrc: NewCellIcon,
|
||||
iconAlt: newCellLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.InsertBelow),
|
||||
commandButtonLabel: newCellLabel,
|
||||
ariaLabel: newCellLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
CommandBarComponentButtonFactory.createDivider(),
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: null,
|
||||
onCommandClick: () => {},
|
||||
commandButtonLabel: null,
|
||||
ariaLabel: cellTypeLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
isDropdown: true,
|
||||
dropdownPlaceholder: cellTypeLabel,
|
||||
dropdownSelectedKey: this.activeCellTypeStr,
|
||||
dropdownWidth: 110,
|
||||
children: [
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: null,
|
||||
onCommandClick: () =>
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.ChangeCellType, cellCodeType),
|
||||
commandButtonLabel: codeLabel,
|
||||
ariaLabel: codeLabel,
|
||||
dropdownItemKey: cellCodeType,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: null,
|
||||
onCommandClick: () =>
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.ChangeCellType, cellMarkdownType),
|
||||
commandButtonLabel: markdownLabel,
|
||||
ariaLabel: markdownLabel,
|
||||
dropdownItemKey: cellMarkdownType,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: null,
|
||||
onCommandClick: () =>
|
||||
this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.ChangeCellType, cellRawType),
|
||||
commandButtonLabel: rawLabel,
|
||||
ariaLabel: rawLabel,
|
||||
dropdownItemKey: cellRawType,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
iconSrc: CopyIcon,
|
||||
iconAlt: copyLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Copy),
|
||||
commandButtonLabel: copyLabel,
|
||||
ariaLabel: copyLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
iconSrc: CopyIcon,
|
||||
iconAlt: copyLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Copy),
|
||||
commandButtonLabel: copyLabel,
|
||||
ariaLabel: copyLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: CutIcon,
|
||||
iconAlt: cutLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Cut),
|
||||
commandButtonLabel: cutLabel,
|
||||
ariaLabel: cutLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: PasteIcon,
|
||||
iconAlt: pasteLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Paste),
|
||||
commandButtonLabel: pasteLabel,
|
||||
ariaLabel: pasteLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
iconSrc: UndoIcon,
|
||||
iconAlt: undoLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Undo),
|
||||
commandButtonLabel: undoLabel,
|
||||
ariaLabel: undoLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
iconSrc: UndoIcon,
|
||||
iconAlt: undoLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Undo),
|
||||
commandButtonLabel: undoLabel,
|
||||
ariaLabel: undoLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: RedoIcon,
|
||||
iconAlt: redoLabel,
|
||||
onCommandClick: () => this.sendMessageToNotebook(NotebookAppContracts.MessageTypes.Redo),
|
||||
commandButtonLabel: redoLabel,
|
||||
ariaLabel: redoLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
if (this.container.hasStorageAnalyticsAfecFeature()) {
|
||||
const arcadiaWorkspaceDropdown: ViewModels.NavbarButtonConfig = {
|
||||
iconSrc: null,
|
||||
iconAlt: workspaceLabel,
|
||||
ariaLabel: workspaceLabel,
|
||||
onCommandClick: () => {},
|
||||
commandButtonLabel: null,
|
||||
hasPopup: false,
|
||||
disabled: this.container.arcadiaWorkspaces.length < 1,
|
||||
isDropdown: false,
|
||||
isArcadiaPicker: true,
|
||||
arcadiaProps: {
|
||||
selectedSparkPool: null,
|
||||
workspaces: this.container.arcadiaWorkspaces(),
|
||||
onSparkPoolSelect: () => {},
|
||||
onCreateNewWorkspaceClicked: () => {
|
||||
this.container.createWorkspace();
|
||||
},
|
||||
onCreateNewSparkPoolClicked: (workspaceResourceId: string) => {
|
||||
this.container.createSparkPool(workspaceResourceId);
|
||||
}
|
||||
}
|
||||
};
|
||||
buttons.splice(1, 0, arcadiaWorkspaceDropdown);
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
|
||||
private async configureServiceEndpoints(kernelName: string) {
|
||||
const notebookConnectionInfo = this.container && this.container.notebookServerInfo();
|
||||
const sparkClusterConnectionInfo = this.container && this.container.sparkClusterConnectionInfo();
|
||||
await NotebookConfigurationUtils.configureServiceEndpoints(
|
||||
this.notebookPath(),
|
||||
notebookConnectionInfo,
|
||||
kernelName,
|
||||
sparkClusterConnectionInfo
|
||||
);
|
||||
}
|
||||
|
||||
private sendMessageToNotebook(type: NotebookAppContracts.MessageTypes, arg?: string): Q.Promise<any> {
|
||||
return this.messageHandler.sendCachedDataMessage(type, arg);
|
||||
}
|
||||
|
||||
/**
|
||||
* The iframe swallows any click event which breaks the logic to dismiss the menu, so we simulate a click from the message
|
||||
*/
|
||||
private simulateClick() {
|
||||
if (!this.tabId) {
|
||||
return;
|
||||
}
|
||||
const event = document.createEvent("Events");
|
||||
event.initEvent("click", true, false);
|
||||
document.getElementById(this.tabId).dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
1
src/Explorer/Tabs/NotebookV2Tab.html
Normal file
1
src/Explorer/Tabs/NotebookV2Tab.html
Normal file
@@ -0,0 +1 @@
|
||||
<div data-bind="react:notebookComponentAdapter" style="height: 100%"></div>
|
||||
435
src/Explorer/Tabs/NotebookV2Tab.ts
Normal file
435
src/Explorer/Tabs/NotebookV2Tab.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import * as _ from "underscore";
|
||||
import * as Q from "q";
|
||||
import * as ko from "knockout";
|
||||
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
|
||||
import NewCellIcon from "../../../images/notebook/Notebook-insert-cell.svg";
|
||||
import CutIcon from "../../../images/notebook/Notebook-cut.svg";
|
||||
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
|
||||
import PasteIcon from "../../../images/notebook/Notebook-paste.svg";
|
||||
import RunIcon from "../../../images/notebook/Notebook-run.svg";
|
||||
import RunAllIcon from "../../../images/notebook/Notebook-run-all.svg";
|
||||
import RestartIcon from "../../../images/notebook/Notebook-restart.svg";
|
||||
import SaveIcon from "../../../images/save-cosmos.svg";
|
||||
import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-outputs.svg";
|
||||
import InterruptKernelIcon from "../../../images/notebook/Notebook-stop.svg";
|
||||
import KillKernelIcon from "../../../images/notebook/Notebook-stop.svg";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Areas, ArmApiVersions } from "../../Common/Constants";
|
||||
import { CommandBarComponentButtonFactory } from "../Menus/CommandBar/CommandBarComponentButtonFactory";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter";
|
||||
import { NotebookConfigurationUtils } from "../../Utils/NotebookConfigurationUtils";
|
||||
import { KernelSpecsDisplay, NotebookClientV2 } from "../Notebook/NotebookClientV2";
|
||||
import { config } from "../../Config";
|
||||
|
||||
export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab {
|
||||
private static clientManager: NotebookClientV2;
|
||||
private container: ViewModels.Explorer;
|
||||
public notebookPath: ko.Observable<string>;
|
||||
private selectedSparkPool: ko.Observable<string>;
|
||||
private notebookComponentAdapter: NotebookComponentAdapter;
|
||||
|
||||
constructor(options: ViewModels.NotebookTabOptions) {
|
||||
super(options);
|
||||
|
||||
this.container = options.container;
|
||||
|
||||
if (!NotebookTabV2.clientManager) {
|
||||
NotebookTabV2.clientManager = new NotebookClientV2({
|
||||
connectionInfo: this.container.notebookServerInfo(),
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
contentProvider: this.container.notebookContentProvider
|
||||
});
|
||||
}
|
||||
|
||||
this.notebookPath = ko.observable(options.notebookContentItem.path);
|
||||
|
||||
this.container.notebookServerInfo.subscribe((newValue: DataModels.NotebookWorkspaceConnectionInfo) => {
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "New notebook server info received.");
|
||||
});
|
||||
|
||||
this.notebookComponentAdapter = new NotebookComponentAdapter({
|
||||
contentItem: options.notebookContentItem,
|
||||
notebooksBasePath: this.container.getNotebookBasePath(),
|
||||
notebookClient: NotebookTabV2.clientManager,
|
||||
onUpdateKernelInfo: this.onKernelUpdate
|
||||
});
|
||||
|
||||
this.selectedSparkPool = ko.observable<string>(null);
|
||||
this.container &&
|
||||
this.container.arcadiaToken.subscribe(async () => {
|
||||
const currentKernel = this.notebookComponentAdapter.getCurrentKernelName();
|
||||
if (!currentKernel) {
|
||||
return;
|
||||
}
|
||||
await this.configureServiceEndpoints(currentKernel);
|
||||
});
|
||||
}
|
||||
|
||||
public onCloseTabButtonClick(): Q.Promise<any> {
|
||||
const cleanup = () => {
|
||||
this.notebookComponentAdapter.notebookShutdown();
|
||||
this.isActive(false);
|
||||
super.onCloseTabButtonClick();
|
||||
};
|
||||
|
||||
if (this.notebookComponentAdapter.isContentDirty()) {
|
||||
this.container.showOkCancelModalDialog(
|
||||
"Close without saving?",
|
||||
`File has unsaved changes, close without saving?`,
|
||||
"Close",
|
||||
cleanup,
|
||||
"Cancel",
|
||||
undefined
|
||||
);
|
||||
return Q.resolve(null);
|
||||
} else {
|
||||
cleanup();
|
||||
return Q.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
public async reconfigureServiceEndpoints() {
|
||||
if (!this.notebookComponentAdapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await this.configureServiceEndpoints(this.notebookComponentAdapter.getCurrentKernelName());
|
||||
}
|
||||
|
||||
protected getContainer(): ViewModels.Explorer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs();
|
||||
|
||||
const saveLabel = "Save";
|
||||
const workspaceLabel = "No Workspace";
|
||||
const kernelLabel = "No Kernel";
|
||||
const runLabel = "Run";
|
||||
const runActiveCellLabel = "Run Active Cell";
|
||||
const runAllLabel = "Run All";
|
||||
const interruptKernelLabel = "Interrupt Kernel";
|
||||
const killKernelLabel = "Halt Kernel";
|
||||
const restartKernelLabel = "Restart Kernel";
|
||||
const clearLabel = "Clear outputs";
|
||||
const newCellLabel = "New Cell";
|
||||
const cellTypeLabel = "Cell Type";
|
||||
const codeLabel = "Code";
|
||||
const markdownLabel = "Markdown";
|
||||
const rawLabel = "Raw";
|
||||
const copyLabel = "Copy";
|
||||
const cutLabel = "Cut";
|
||||
const pasteLabel = "Paste";
|
||||
const undoLabel = "Undo";
|
||||
const redoLabel = "Redo";
|
||||
const cellCodeType = "code";
|
||||
const cellMarkdownType = "markdown";
|
||||
const cellRawType = "raw";
|
||||
let buttons: ViewModels.NavbarButtonConfig[] = [
|
||||
{
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: saveLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookSave(),
|
||||
commandButtonLabel: saveLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: saveLabel
|
||||
},
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: kernelLabel,
|
||||
onCommandClick: () => {},
|
||||
commandButtonLabel: null,
|
||||
hasPopup: false,
|
||||
disabled: availableKernels.length < 1,
|
||||
isDropdown: true,
|
||||
dropdownPlaceholder: kernelLabel,
|
||||
dropdownSelectedKey: this.notebookComponentAdapter.getSelectedKernelName(), //this.currentKernelName,
|
||||
dropdownWidth: 100,
|
||||
children: availableKernels.map(
|
||||
(kernel: KernelSpecsDisplay) =>
|
||||
({
|
||||
iconSrc: null,
|
||||
iconAlt: kernel.displayName,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookChangeKernel(kernel.name),
|
||||
commandButtonLabel: kernel.displayName,
|
||||
dropdownItemKey: kernel.name,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: kernel.displayName
|
||||
} as ViewModels.NavbarButtonConfig)
|
||||
),
|
||||
ariaLabel: kernelLabel
|
||||
},
|
||||
{
|
||||
iconSrc: RunIcon,
|
||||
iconAlt: runLabel,
|
||||
onCommandClick: () => {
|
||||
this.notebookComponentAdapter.notebookRunAndAdvance();
|
||||
this.traceTelemetry(Action.ExecuteCell);
|
||||
},
|
||||
commandButtonLabel: runLabel,
|
||||
ariaLabel: runLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
iconSrc: RunIcon,
|
||||
iconAlt: runActiveCellLabel,
|
||||
onCommandClick: () => {
|
||||
this.notebookComponentAdapter.notebookRunAndAdvance();
|
||||
this.traceTelemetry(Action.ExecuteCell);
|
||||
},
|
||||
commandButtonLabel: runActiveCellLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: runActiveCellLabel
|
||||
},
|
||||
{
|
||||
iconSrc: RunAllIcon,
|
||||
iconAlt: runAllLabel,
|
||||
onCommandClick: () => {
|
||||
this.notebookComponentAdapter.notebookRunAll();
|
||||
this.traceTelemetry(Action.ExecuteAllCells);
|
||||
},
|
||||
commandButtonLabel: runAllLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: runAllLabel
|
||||
},
|
||||
{
|
||||
iconSrc: InterruptKernelIcon,
|
||||
iconAlt: interruptKernelLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookInterruptKernel(),
|
||||
commandButtonLabel: interruptKernelLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: interruptKernelLabel
|
||||
},
|
||||
{
|
||||
iconSrc: KillKernelIcon,
|
||||
iconAlt: killKernelLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookKillKernel(),
|
||||
commandButtonLabel: killKernelLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: killKernelLabel
|
||||
},
|
||||
{
|
||||
iconSrc: RestartIcon,
|
||||
iconAlt: restartKernelLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookRestartKernel(),
|
||||
commandButtonLabel: restartKernelLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: restartKernelLabel
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
iconSrc: ClearAllOutputsIcon,
|
||||
iconAlt: clearLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookClearAllOutputs(),
|
||||
commandButtonLabel: clearLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
ariaLabel: clearLabel
|
||||
},
|
||||
{
|
||||
iconSrc: NewCellIcon,
|
||||
iconAlt: newCellLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookInsertBelow(),
|
||||
commandButtonLabel: newCellLabel,
|
||||
ariaLabel: newCellLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
CommandBarComponentButtonFactory.createDivider(),
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: null,
|
||||
onCommandClick: () => {},
|
||||
commandButtonLabel: null,
|
||||
ariaLabel: cellTypeLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
isDropdown: true,
|
||||
dropdownPlaceholder: cellTypeLabel,
|
||||
dropdownSelectedKey: this.notebookComponentAdapter.getActiveCellTypeStr(),
|
||||
dropdownWidth: 110,
|
||||
children: [
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: null,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookChangeCellType(cellCodeType),
|
||||
commandButtonLabel: codeLabel,
|
||||
ariaLabel: codeLabel,
|
||||
dropdownItemKey: cellCodeType,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: null,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookChangeCellType(cellMarkdownType),
|
||||
commandButtonLabel: markdownLabel,
|
||||
ariaLabel: markdownLabel,
|
||||
dropdownItemKey: cellMarkdownType,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: null,
|
||||
iconAlt: null,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookChangeCellType(cellRawType),
|
||||
commandButtonLabel: rawLabel,
|
||||
ariaLabel: rawLabel,
|
||||
dropdownItemKey: cellRawType,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
iconSrc: CopyIcon,
|
||||
iconAlt: copyLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebokCopy(),
|
||||
commandButtonLabel: copyLabel,
|
||||
ariaLabel: copyLabel,
|
||||
hasPopup: false,
|
||||
disabled: false,
|
||||
children: [
|
||||
{
|
||||
iconSrc: CopyIcon,
|
||||
iconAlt: copyLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebokCopy(),
|
||||
commandButtonLabel: copyLabel,
|
||||
ariaLabel: copyLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: CutIcon,
|
||||
iconAlt: cutLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookCut(),
|
||||
commandButtonLabel: cutLabel,
|
||||
ariaLabel: cutLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
iconSrc: PasteIcon,
|
||||
iconAlt: pasteLabel,
|
||||
onCommandClick: () => this.notebookComponentAdapter.notebookPaste(),
|
||||
commandButtonLabel: pasteLabel,
|
||||
ariaLabel: pasteLabel,
|
||||
hasPopup: false,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
}
|
||||
// TODO: Uncomment when undo/redo is reimplemented in nteract
|
||||
];
|
||||
|
||||
if (this.container.hasStorageAnalyticsAfecFeature()) {
|
||||
const arcadiaWorkspaceDropdown: ViewModels.NavbarButtonConfig = {
|
||||
iconSrc: null,
|
||||
iconAlt: workspaceLabel,
|
||||
ariaLabel: workspaceLabel,
|
||||
onCommandClick: () => {},
|
||||
commandButtonLabel: null,
|
||||
hasPopup: false,
|
||||
disabled: this.container.arcadiaWorkspaces.length < 1,
|
||||
isDropdown: false,
|
||||
isArcadiaPicker: true,
|
||||
arcadiaProps: {
|
||||
selectedSparkPool: this.selectedSparkPool(),
|
||||
workspaces: this.container.arcadiaWorkspaces(),
|
||||
onSparkPoolSelect: this.onSparkPoolSelect,
|
||||
onCreateNewWorkspaceClicked: () => {
|
||||
this.container.createWorkspace();
|
||||
},
|
||||
onCreateNewSparkPoolClicked: (workspaceResourceId: string) => {
|
||||
this.container.createSparkPool(workspaceResourceId);
|
||||
}
|
||||
}
|
||||
};
|
||||
buttons.splice(1, 0, arcadiaWorkspaceDropdown);
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
|
||||
private onSparkPoolSelect = (evt: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, item: any) => {
|
||||
if (!item || !item.text) {
|
||||
this.selectedSparkPool(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.container &&
|
||||
this.container.arcadiaWorkspaces &&
|
||||
this.container.arcadiaWorkspaces() &&
|
||||
this.container.arcadiaWorkspaces().forEach(async workspace => {
|
||||
if (workspace && workspace.name && workspace.sparkPools) {
|
||||
const selectedPoolIndex = _.findIndex(workspace.sparkPools, pool => pool && pool.name === item.text);
|
||||
if (selectedPoolIndex >= 0) {
|
||||
const selectedPool = workspace.sparkPools[selectedPoolIndex];
|
||||
if (selectedPool && selectedPool.name) {
|
||||
this.container.sparkClusterConnectionInfo({
|
||||
userName: undefined,
|
||||
password: undefined,
|
||||
endpoints: [
|
||||
{
|
||||
endpoint: `https://${workspace.name}.${config.ARCADIA_LIVY_ENDPOINT_DNS_ZONE}/livyApi/versions/${ArmApiVersions.arcadiaLivy}/sparkPools/${selectedPool.name}/`,
|
||||
kind: DataModels.SparkClusterEndpointKind.Livy
|
||||
}
|
||||
]
|
||||
});
|
||||
this.selectedSparkPool(item.text);
|
||||
await this.reconfigureServiceEndpoints();
|
||||
this.container.sparkClusterConnectionInfo.valueHasMutated();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private onKernelUpdate = async () => {
|
||||
await this.configureServiceEndpoints(this.notebookComponentAdapter.getCurrentKernelName()).catch(reason => {
|
||||
/* Erroring is ok here */
|
||||
});
|
||||
this.updateNavbarWithTabsButtons();
|
||||
};
|
||||
|
||||
private async configureServiceEndpoints(kernelName: string) {
|
||||
const notebookConnectionInfo = this.container && this.container.notebookServerInfo();
|
||||
const sparkClusterConnectionInfo = this.container && this.container.sparkClusterConnectionInfo();
|
||||
await NotebookConfigurationUtils.configureServiceEndpoints(
|
||||
this.notebookPath(),
|
||||
notebookConnectionInfo,
|
||||
kernelName,
|
||||
sparkClusterConnectionInfo
|
||||
);
|
||||
}
|
||||
|
||||
private traceTelemetry(actionType: number) {
|
||||
TelemetryProcessor.trace(actionType, ActionModifiers.Mark, {
|
||||
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.Notebook
|
||||
});
|
||||
}
|
||||
}
|
||||
1
src/Explorer/Tabs/NotebookViewerTab.html
Normal file
1
src/Explorer/Tabs/NotebookViewerTab.html
Normal file
@@ -0,0 +1 @@
|
||||
<div style="height: 100%" data-bind="react:notebookViewerComponentAdapter, setTemplateReady: true"></div>
|
||||
71
src/Explorer/Tabs/NotebookViewerTab.tsx
Normal file
71
src/Explorer/Tabs/NotebookViewerTab.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
import * as React from "react";
|
||||
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
|
||||
import { NotebookViewerComponent } from "../Controls/NotebookViewer/NotebookViewerComponent";
|
||||
|
||||
/**
|
||||
* Notebook Viewer tab
|
||||
*/
|
||||
class NotebookViewerComponentAdapter implements ReactAdapter {
|
||||
// parameters: true: show, false: hide
|
||||
public parameters: ko.Computed<boolean>;
|
||||
constructor(
|
||||
private notebookUrl: string,
|
||||
private notebookName: string,
|
||||
private container: ViewModels.Explorer,
|
||||
private notebookMetadata: DataModels.NotebookMetadata
|
||||
) {}
|
||||
|
||||
public renderComponent(): JSX.Element {
|
||||
return this.parameters() ? (
|
||||
<NotebookViewerComponent
|
||||
notebookUrl={this.notebookUrl}
|
||||
notebookMetadata={this.notebookMetadata}
|
||||
notebookName={this.notebookName}
|
||||
container={this.container}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class NotebookViewerTab extends TabsBase implements ViewModels.Tab {
|
||||
private container: ViewModels.Explorer;
|
||||
public notebookViewerComponentAdapter: NotebookViewerComponentAdapter;
|
||||
public notebookUrl: string;
|
||||
|
||||
constructor(options: ViewModels.NotebookViewerTabOptions) {
|
||||
super(options);
|
||||
this.container = options.container;
|
||||
this.notebookUrl = options.notebookUrl;
|
||||
this.notebookViewerComponentAdapter = new NotebookViewerComponentAdapter(
|
||||
options.notebookUrl,
|
||||
options.notebookName,
|
||||
options.container,
|
||||
options.notebookMetadata
|
||||
);
|
||||
|
||||
this.notebookViewerComponentAdapter.parameters = ko.computed<boolean>(() => {
|
||||
if (this.isTemplateReady() && this.container.isNotebookEnabled()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
protected getContainer(): ViewModels.Explorer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
}
|
||||
334
src/Explorer/Tabs/QueryTab.html
Normal file
334
src/Explorer/Tabs/QueryTab.html
Normal file
@@ -0,0 +1,334 @@
|
||||
<div
|
||||
class="tab-pane"
|
||||
data-bind="setTemplateReady: true,
|
||||
attr:{
|
||||
id: tabId
|
||||
}"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div class="tabPaneContentContainer">
|
||||
<div class="mongoQueryHelper" data-bind="visible: isPreferredApiMongoDB && sqlQueryEditorContent().length === 0">
|
||||
Start by writing a Mongo query, for example: <strong>{'id':'foo'}</strong> or <strong>{ }</strong> to get all the
|
||||
documents.
|
||||
</div>
|
||||
<div class="queryEditorWithSplitter" data-bind="attr: { id: queryEditorId }">
|
||||
<editor
|
||||
class="queryEditor"
|
||||
data-bind="css: { mongoQueryEditor: isPreferredApiMongoDB }"
|
||||
params="{
|
||||
content: initialEditorContent,
|
||||
contentType: monacoSettings.language,
|
||||
isReadOnly: monacoSettings.readOnly,
|
||||
lineNumbers: 'on',
|
||||
ariaLabel: 'Editing Query',
|
||||
updatedContent: sqlQueryEditorContent,
|
||||
selectedContent: selectedContent
|
||||
}"
|
||||
></editor>
|
||||
<!-- Splitter - Start -->
|
||||
<div class="splitter ui-resizable-handle ui-resizable-s" data-bind="attr: { id: splitterId }">
|
||||
<img class="queryEditorHorizontalSplitter" src="/HorizontalSplitter.svg" alt="Splitter" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Splitter - End -->
|
||||
|
||||
<!-- Script for results metadata that is common to all APIs -->
|
||||
<script type="text/html" id="result-metadata-template">
|
||||
<span>
|
||||
<span data-bind="text: showingDocumentsDisplayText"></span>
|
||||
</span>
|
||||
<span class="queryResultDivider" data-bind="visible: fetchNextPageButton.enabled"> | </span>
|
||||
<span class="queryResultNextEnable" data-bind="visible: fetchNextPageButton.enabled">
|
||||
<a data-bind="click: onFetchNextPageClick">
|
||||
<span>Load more</span>
|
||||
<img class="queryResultnextImg" src="/Query-Editor-Next.svg" alt="Fetch next page">
|
||||
</a>
|
||||
</span>
|
||||
</script>
|
||||
|
||||
<!-- Query Errors Tab - Start-->
|
||||
<div class="active queryErrorsHeaderContainer" data-bind="visible: errors().length > 0">
|
||||
<span class="queryErrors" data-toggle="tab" data-bind="attr: { href: '#queryerrors' + tabId }">Errors</span>
|
||||
</div>
|
||||
<!-- Query Errors Tab - End -->
|
||||
|
||||
<!-- Query Results & Errors Content Container - Start-->
|
||||
<div class="queryResultErrorContentContainer">
|
||||
<div
|
||||
class="queryEditorWatermark"
|
||||
data-bind="visible: allResultsMetadata().length === 0 && errors().length === 0 && !queryResults() && !isExecuting()"
|
||||
>
|
||||
<p><img src="/RunQuery.png" alt="Execute Query Watermark" /></p>
|
||||
<p class="queryEditorWatermarkText">Execute a query to see the results</p>
|
||||
</div>
|
||||
<div
|
||||
class="queryResultsErrorsContent"
|
||||
data-bind="visible: allResultsMetadata().length > 0 || errors().length > 0 || queryResults()"
|
||||
>
|
||||
<div class="togglesWithMetadata" data-bind="visible: errors().length === 0">
|
||||
<div
|
||||
class="toggles"
|
||||
aria-label="Successful execution"
|
||||
id="execute-query-toggles"
|
||||
data-bind="event: { keydown: onToggleKeyDown }"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="tab">
|
||||
<input type="radio" class="radio" value="result" />
|
||||
<span
|
||||
class="toggleSwitch"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-bind="click: toggleResult, css:{ selectedToggle: isResultToggled(), unselectedToggle: !isResultToggled() }"
|
||||
aria-label="Results"
|
||||
>Results</span
|
||||
>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<input type="radio" class="radio" value="logs" />
|
||||
<span
|
||||
class="toggleSwitch"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-bind="click: toggleMetrics, css:{ selectedToggle: isMetricsToggled(), unselectedToggle: !isMetricsToggled() }"
|
||||
aria-label="Query stats"
|
||||
>Query Stats</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="result-metadata"
|
||||
data-bind="template: { name: 'result-metadata-template' }, visible: isResultToggled()"
|
||||
></div>
|
||||
</div>
|
||||
<json-editor
|
||||
params="{ content: queryResults, isReadOnly: true, ariaLabel: 'Query results' }"
|
||||
data-bind="visible: queryResults().length > 0 && isResultToggled() && allResultsMetadata().length > 0 && errors().length === 0"
|
||||
>
|
||||
</json-editor>
|
||||
<div
|
||||
class="queryMetricsSummaryContainer"
|
||||
data-bind="visible: isMetricsToggled() && allResultsMetadata().length > 0 && errors().length === 0"
|
||||
>
|
||||
<table class="queryMetricsSummary">
|
||||
<thead class="queryMetricsSummaryHead">
|
||||
<tr class="queryMetricsSummaryHeader queryMetricsSummaryTuple">
|
||||
<th title="METRIC">METRIC</th>
|
||||
<th></th>
|
||||
<th title="VALUE">VALUE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="queryMetricsSummaryBody" data-bind="with: aggregatedQueryMetrics">
|
||||
<tr class="queryMetricsSummaryTuple">
|
||||
<td title="Request Charge">Request Charge</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<span
|
||||
data-bind="text: $parent.requestChargeDisplayText, attr: { title: $parent.requestChargeDisplayText }"
|
||||
></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple">
|
||||
<td title="Showing Results">Showing Results</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<span
|
||||
data-bind="text: $parent.showingDocumentsDisplayText, attr: { title: $parent.showingDocumentsDisplayText }"
|
||||
></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Retrieved document count">Retrieved document count</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Total number of retrieved documents</span>
|
||||
</span>
|
||||
</td>
|
||||
<td><span data-bind="text: retrievedDocumentCount, attr: { title: retrievedDocumentCount }"></span></td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Retrieved document size">Retrieved document size</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Total size of retrieved documents in bytes</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span data-bind="text: retrievedDocumentSize, attr: { title: retrievedDocumentSize }"></span>
|
||||
<span>bytes</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Output document count">Output document count</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Number of output documents</span>
|
||||
</span>
|
||||
</td>
|
||||
<td><span data-bind="text: outputDocumentCount, attr: { title: outputDocumentCount }"></span></td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Output document size">Output document size</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Total size of output documents in bytes</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span data-bind="text: outputDocumentSize, attr: { title: outputDocumentSize }"></span>
|
||||
<span>bytes</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Index hit document count">Index hit document count</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Total number of documents matched by the filter</span>
|
||||
</span>
|
||||
</td>
|
||||
<td><span data-bind="text: indexHitDocumentCount, attr: { title: indexHitDocumentCount }"></span></td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Index lookup time">Index lookup time</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Time spent in physical index layer</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span data-bind="text: indexLookupTime, attr: { title: indexLookupTime }"></span> <span>ms</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Document load time">Document load time</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Time spent in loading documents</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span data-bind="text: documentLoadTime, attr: { title: documentLoadTime }"></span> <span>ms</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Query engine execution time">Query engine execution time</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText queryEngineExeTimeInfo"
|
||||
>Time spent by the query engine to execute the query expression (excludes other execution times
|
||||
like load documents or write results)</span
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
data-bind="text: runtimeExecutionTimes.queryEngineExecutionTime, attr: { title: runtimeExecutionTimes.queryEngineExecutionTime }"
|
||||
></span>
|
||||
<span>ms</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="System function execution time">System function execution time</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Total time spent executing system (built-in) functions</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
data-bind="text: runtimeExecutionTimes.systemFunctionExecutionTime, attr: { title: runtimeExecutionTimes.systemFunctionExecutionTime }"
|
||||
></span>
|
||||
<span>ms</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="User defined function execution time">User defined function execution time</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Total time spent executing user-defined functions</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
data-bind="text: runtimeExecutionTimes.userDefinedFunctionExecutionTime, attr: { title: runtimeExecutionTimes.userDefinedFunctionExecutionTime }"
|
||||
></span>
|
||||
<span>ms</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<td><span title="Document write time">Document write time</span></td>
|
||||
<td>
|
||||
<span class="queryMetricInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="queryMetricTooltipText">Time spent to write query result set to response buffer</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span data-bind="text: documentWriteTime, attr: { title: documentWriteTime }"></span> <span>ms</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.roundTrips() != null">
|
||||
<td title="Round Trips">Round Trips</td>
|
||||
<td></td>
|
||||
<td><span data-bind="text: $parent.roundTrips, attr: { title: $parent.roundTrips }"></span></td>
|
||||
</tr>
|
||||
<!-- TODO: Report activity id for mongo queries -->
|
||||
<tr class="queryMetricsSummaryTuple" data-bind="visible: $parent.activityId() != null">
|
||||
<td title="Activity id">Activity id</td>
|
||||
<td></td>
|
||||
<td><span data-bind="text: $parent.activityId, attr: { title: $parent.activityId }"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="downloadMetricsLinkContainer" data-bind="visible: $parent.isQueryMetricsEnabled">
|
||||
<a
|
||||
id="downloadMetricsLink"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-bind="event: { click: onDownloadQueryMetricsCsvClick, keypress: onDownloadQueryMetricsCsvKeyPress }"
|
||||
>
|
||||
<img class="downloadCsvImg" src="/DownloadQuery.svg" alt="download query metrics csv" />
|
||||
<span>Per-partition query metrics (CSV)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Query Errors Content - Start-->
|
||||
<div
|
||||
class="tab-pane active"
|
||||
data-bind="
|
||||
id: {
|
||||
href: 'queryerrors' + tabId
|
||||
},
|
||||
visible: errors().length > 0"
|
||||
>
|
||||
<!-- ko foreach: errors -->
|
||||
<div class="errorContent">
|
||||
<span class="errorMessage" data-bind="text: $data.message"></span>
|
||||
<span class="errorDetailsLink">
|
||||
<a
|
||||
data-bind="click: $parent.onErrorDetailsClick, event: { keypress: $parent.onErrorDetailsKeyPress }"
|
||||
id="error-display"
|
||||
tabindex="0"
|
||||
aria-label="Error details link"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
<!-- Query Errors Content - End-->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results & Errors Content Container - End-->
|
||||
</div>
|
||||
</div>
|
||||
311
src/Explorer/Tabs/QueryTab.less
Normal file
311
src/Explorer/Tabs/QueryTab.less
Normal file
@@ -0,0 +1,311 @@
|
||||
@import "../../../less/Common/Constants";
|
||||
@import "../../../less/Common/TabCommon";
|
||||
|
||||
@MongoQueryEditorHeight: 50px;
|
||||
@ResultsTextFontWeight: 600;
|
||||
@ToggleHeight: 30px;
|
||||
@ToggleWidth: 250px;
|
||||
@QueryEngineExeInfo: 305px;
|
||||
|
||||
.tab-pane {
|
||||
.tabContentContainer();
|
||||
|
||||
.tabPaneContentContainer {
|
||||
.tabContentContainer();
|
||||
|
||||
.mongoQueryHelper {
|
||||
margin:@DefaultSpace 0px 0px 44px;
|
||||
position: absolute;
|
||||
top: 115px; //this is to avoid the jump of query editor
|
||||
}
|
||||
|
||||
.queryEditorWithSplitter {
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin-left: @SmallSpace;
|
||||
|
||||
.queryEditor {
|
||||
.flex-display();
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin-top: @SmallSpace;
|
||||
|
||||
.jsonEditor {
|
||||
border: none;
|
||||
margin-top: @SmallSpace;
|
||||
}
|
||||
}
|
||||
|
||||
.queryEditor.mongoQueryEditor {
|
||||
margin-top: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queryEditorHorizontalSplitter {
|
||||
margin: auto;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.queryErrorsHeaderContainer {
|
||||
padding: 24px @LargeSpace 0px @MediumSpace;
|
||||
|
||||
.queryErrors {
|
||||
font-size: @mediumFontSize;
|
||||
list-style-type: none;
|
||||
color: @BaseDark;
|
||||
font-weight: bold;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.queryResultErrorContentContainer {
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
font-size: @DefaultFontSize;
|
||||
padding: @DefaultSpace;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.queryEditorWatermark {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
height: 25vh; // this is to align the water mark in center of the layout.
|
||||
|
||||
p {
|
||||
margin-bottom: @LargeSpace;
|
||||
color: @BaseHigh;
|
||||
}
|
||||
|
||||
.queryEditorWatermarkText {
|
||||
color: @BaseHigh;
|
||||
font-size: @DefaultFontSize;
|
||||
font-family: @DataExplorerFont;
|
||||
}
|
||||
}
|
||||
|
||||
.queryResultsErrorsContent {
|
||||
height: 100%;
|
||||
margin-left: @MediumSpace;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
|
||||
|
||||
.togglesWithMetadata {
|
||||
margin-top: @MediumSpace;
|
||||
|
||||
.toggles {
|
||||
height: @ToggleHeight;
|
||||
width: @ToggleWidth;
|
||||
margin-left: @MediumSpace;
|
||||
|
||||
&:focus {
|
||||
.focus();
|
||||
}
|
||||
|
||||
.tab {
|
||||
margin-right: @MediumSpace;
|
||||
}
|
||||
|
||||
.toggleSwitch {
|
||||
.toggleSwitch();
|
||||
}
|
||||
|
||||
.selectedToggle {
|
||||
.selectedToggle();
|
||||
}
|
||||
|
||||
.unselectedToggle {
|
||||
.unselectedToggle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-metadata {
|
||||
padding: @LargeSpace @SmallSpace @MediumSpace @MediumSpace;
|
||||
|
||||
.queryResultDivider {
|
||||
margin-left: @SmallSpace;
|
||||
margin-right: @SmallSpace;
|
||||
}
|
||||
|
||||
.queryResultNextEnable {
|
||||
color: @AccentMediumHigh;
|
||||
font-size: @mediumFontSize;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
height: @ImgHeight;
|
||||
width: @ImgWidth;
|
||||
margin-left: @SmallSpace;
|
||||
}
|
||||
}
|
||||
|
||||
.queryResultNextDisable {
|
||||
color: @BaseMediumHigh;
|
||||
opacity: 0.5;
|
||||
font-size: @mediumFontSize;
|
||||
|
||||
img {
|
||||
height: @ImgHeight;
|
||||
width: @ImgWidth;
|
||||
margin-left: @SmallSpace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-pane.active {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
.flex-display();
|
||||
width: 60%;
|
||||
white-space: nowrap;
|
||||
font-size: @mediumFontSize;
|
||||
padding: 0px @MediumSpace 0px @MediumSpace;
|
||||
|
||||
.errorMessage {
|
||||
padding: @SmallSpace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.errorDetailsLink {
|
||||
cursor: pointer;
|
||||
padding: @SmallSpace;
|
||||
}
|
||||
|
||||
.queryMetricsSummaryContainer {
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
overflow: hidden;
|
||||
|
||||
.queryMetricsSummary {
|
||||
margin: @LargeSpace @LargeSpace 0px @DefaultSpace;
|
||||
table-layout: fixed;
|
||||
display: block;
|
||||
height: auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.queryMetricsSummaryHead {
|
||||
.flex-display();
|
||||
}
|
||||
|
||||
.queryMetricsSummaryHeader.queryMetricsSummaryTuple {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.queryMetricsSummaryBody {
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
}
|
||||
|
||||
.queryMetricsSummaryTuple {
|
||||
border-bottom: 1px solid @BaseMedium;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
.flex-display();
|
||||
th, td {
|
||||
padding: @DefaultSpace;
|
||||
|
||||
&:nth-child(1) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 50%;
|
||||
}
|
||||
|
||||
.queryMetricInfoTooltip {
|
||||
.infoTooltip();
|
||||
|
||||
&:hover .queryMetricTooltipText {
|
||||
.tooltipVisible();
|
||||
}
|
||||
|
||||
&:focus .queryMetricTooltipText {
|
||||
.tooltipVisible();
|
||||
}
|
||||
|
||||
.queryMetricTooltipText {
|
||||
top: -50px;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
left: 6px;
|
||||
visibility: hidden;
|
||||
background-color: @BaseHigh;
|
||||
color: @BaseLight;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
padding: @MediumSpace;
|
||||
|
||||
&::after {
|
||||
border-width: (2 * @MediumSpace) (2 * @MediumSpace) 0px 0px;
|
||||
bottom: -14px;
|
||||
.tooltipTextAfter();
|
||||
}
|
||||
}
|
||||
|
||||
.queryEngineExeTimeInfo {
|
||||
width: @QueryEngineExeInfo;
|
||||
top: -85px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.downloadMetricsLinkContainer {
|
||||
margin: 24px 0px 24px @MediumSpace;
|
||||
|
||||
#downloadMetricsLink {
|
||||
color: @BaseHigh;
|
||||
padding: @DefaultSpace;
|
||||
font-size: @mediumFontSize;
|
||||
border: @ButtonBorderWidth solid @BaseLight;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.hover();
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: @ButtonBorderWidth dashed @AccentMedium;
|
||||
.active();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json-editor {
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: @SmallSpace 0px @SmallSpace @MediumSpace;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/Explorer/Tabs/QueryTab.test.ts
Normal file
98
src/Explorer/Tabs/QueryTab.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import Explorer from "../Explorer";
|
||||
import { CollectionStub, DatabaseStub } from "../../Explorer/OpenActionsStubs";
|
||||
import QueryTab from "./QueryTab";
|
||||
|
||||
jest.mock("./NotebookTab");
|
||||
|
||||
describe("Query Tab", () => {
|
||||
function getNewQueryTabForContainer(container: ViewModels.Explorer): ViewModels.QueryTab {
|
||||
const database: ViewModels.Database = new DatabaseStub({
|
||||
container: container,
|
||||
id: ko.observable<string>("test"),
|
||||
isDatabaseShared: () => false
|
||||
});
|
||||
const collection: ViewModels.Collection = new CollectionStub({
|
||||
container: container,
|
||||
databaseId: "test",
|
||||
id: ko.observable<string>("test")
|
||||
});
|
||||
|
||||
return new QueryTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Query,
|
||||
collection: collection,
|
||||
database: database,
|
||||
title: "",
|
||||
tabPath: "",
|
||||
documentClientUtility: container.documentClientUtility,
|
||||
selfLink: "",
|
||||
isActive: ko.observable<boolean>(false),
|
||||
hashLocation: "",
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
}
|
||||
|
||||
describe("shouldSetSystemPartitionKeyContainerPartitionKeyValueUndefined", () => {
|
||||
const collection: ViewModels.Collection = new CollectionStub({
|
||||
id: "withoutsystempk",
|
||||
partitionKey: {
|
||||
systemKey: true
|
||||
}
|
||||
});
|
||||
|
||||
const collectionSystemPK: ViewModels.Collection = new CollectionStub({
|
||||
id: "withsystempk",
|
||||
partitionKey: {
|
||||
systemKey: true
|
||||
}
|
||||
});
|
||||
|
||||
it("no container with system pk, should not set partition key option", () => {
|
||||
const iteratorOptions = QueryTab.getIteratorOptions(collection);
|
||||
expect(iteratorOptions.initialHeaders).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isQueryMetricsEnabled()", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
});
|
||||
|
||||
it("should be true for accounts using SQL API", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.DocumentDB.toLowerCase());
|
||||
const queryTab: ViewModels.QueryTab = getNewQueryTabForContainer(explorer);
|
||||
expect(queryTab.isQueryMetricsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false for accounts using other APIs", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.Graph.toLowerCase());
|
||||
const queryTab: ViewModels.QueryTab = getNewQueryTabForContainer(explorer);
|
||||
expect(queryTab.isQueryMetricsEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Save Queries command button", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
});
|
||||
|
||||
it("should be visible when using a supported API", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.DocumentDB);
|
||||
const queryTab: ViewModels.QueryTab = getNewQueryTabForContainer(explorer);
|
||||
expect(queryTab.saveQueryButton.visible()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not be visible when using an unsupported API", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.MongoDB);
|
||||
const queryTab: ViewModels.QueryTab = getNewQueryTabForContainer(explorer);
|
||||
expect(queryTab.saveQueryButton.visible()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
632
src/Explorer/Tabs/QueryTab.ts
Normal file
632
src/Explorer/Tabs/QueryTab.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import TabsBase from "./TabsBase";
|
||||
import { HashMap } from "../../Common/HashMap";
|
||||
import * as HeadersUtility from "../../Common/HeadersUtility";
|
||||
import { Logger } from "../../Common/Logger";
|
||||
import { Splitter, SplitterBounds, SplitterDirection } from "../../Common/Splitter";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";
|
||||
import { QueryUtils } from "../../Utils/QueryUtils";
|
||||
import SaveQueryIcon from "../../../images/save-cosmos.svg";
|
||||
|
||||
import { MinimalQueryIterator } from "../../Common/IteratorUtilities";
|
||||
|
||||
enum ToggleState {
|
||||
Result,
|
||||
QueryMetrics
|
||||
}
|
||||
|
||||
export default class QueryTab extends TabsBase implements ViewModels.QueryTab, ViewModels.WaitsForTemplate {
|
||||
public queryEditorId: string;
|
||||
public executeQueryButton: ViewModels.Button;
|
||||
public fetchNextPageButton: ViewModels.Button;
|
||||
public saveQueryButton: ViewModels.Button;
|
||||
public initialEditorContent: ko.Observable<string>;
|
||||
public sqlQueryEditorContent: ko.Observable<string>;
|
||||
public selectedContent: ko.Observable<string>;
|
||||
public sqlStatementToExecute: ko.Observable<string>;
|
||||
public queryResults: ko.Observable<string>;
|
||||
public errors: ko.ObservableArray<ViewModels.QueryError>;
|
||||
public statusMessge: ko.Observable<string>;
|
||||
public statusIcon: ko.Observable<string>;
|
||||
public allResultsMetadata: ko.ObservableArray<ViewModels.QueryResultsMetadata>;
|
||||
public showingDocumentsDisplayText: ko.Observable<string>;
|
||||
public requestChargeDisplayText: ko.Observable<string>;
|
||||
public isTemplateReady: ko.Observable<boolean>;
|
||||
public splitterId: string;
|
||||
public splitter: Splitter;
|
||||
public isPreferredApiMongoDB: boolean;
|
||||
|
||||
public queryMetrics: ko.Observable<HashMap<DataModels.QueryMetrics>>;
|
||||
public aggregatedQueryMetrics: ko.Observable<DataModels.QueryMetrics>;
|
||||
public activityId: ko.Observable<string>;
|
||||
public roundTrips: ko.Observable<number>;
|
||||
public toggleState: ko.Observable<ToggleState>;
|
||||
public isQueryMetricsEnabled: ko.Computed<boolean>;
|
||||
|
||||
protected monacoSettings: ViewModels.MonacoEditorSettings;
|
||||
private _executeQueryButtonTitle: ko.Observable<string>;
|
||||
protected _iterator: MinimalQueryIterator;
|
||||
private _selfLink: string;
|
||||
private _isSaveQueriesEnabled: ko.Computed<boolean>;
|
||||
private _resourceTokenPartitionKey: string;
|
||||
|
||||
_partitionKey: DataModels.PartitionKey;
|
||||
|
||||
constructor(options: ViewModels.QueryTabOptions) {
|
||||
super(options);
|
||||
this.queryEditorId = `queryeditor${this.tabId}`;
|
||||
this.showingDocumentsDisplayText = ko.observable<string>();
|
||||
this.requestChargeDisplayText = ko.observable<string>();
|
||||
const defaultQueryText = options.queryText != void 0 ? options.queryText : "SELECT * FROM c";
|
||||
this.initialEditorContent = ko.observable<string>(defaultQueryText);
|
||||
this.sqlQueryEditorContent = ko.observable<string>(defaultQueryText);
|
||||
this._executeQueryButtonTitle = ko.observable<string>("Execute Query");
|
||||
this.selectedContent = ko.observable<string>();
|
||||
this.selectedContent.subscribe((selectedContent: string) => {
|
||||
if (!selectedContent.trim()) {
|
||||
this._executeQueryButtonTitle("Execute Query");
|
||||
} else {
|
||||
this._executeQueryButtonTitle("Execute Selection");
|
||||
}
|
||||
});
|
||||
this.sqlStatementToExecute = ko.observable<string>("");
|
||||
this.queryResults = ko.observable<string>("");
|
||||
this.statusMessge = ko.observable<string>();
|
||||
this.statusIcon = ko.observable<string>();
|
||||
this.allResultsMetadata = ko.observableArray<ViewModels.QueryResultsMetadata>([]);
|
||||
this.errors = ko.observableArray<ViewModels.QueryError>([]);
|
||||
this._partitionKey = options.partitionKey;
|
||||
this._resourceTokenPartitionKey = options.resourceTokenPartitionKey;
|
||||
this._selfLink = options.selfLink;
|
||||
this.splitterId = this.tabId + "_splitter";
|
||||
this.isPreferredApiMongoDB = false;
|
||||
this.aggregatedQueryMetrics = ko.observable<DataModels.QueryMetrics>();
|
||||
this._resetAggregateQueryMetrics();
|
||||
this.queryMetrics = ko.observable<HashMap<DataModels.QueryMetrics>>(new HashMap<DataModels.QueryMetrics>());
|
||||
this.queryMetrics.subscribe((metrics: HashMap<DataModels.QueryMetrics>) =>
|
||||
this.aggregatedQueryMetrics(this._aggregateQueryMetrics(metrics))
|
||||
);
|
||||
this.isQueryMetricsEnabled = ko.computed<boolean>(() => {
|
||||
return (
|
||||
(this.collection && this.collection.container && this.collection.container.isPreferredApiDocumentDB()) || false
|
||||
);
|
||||
});
|
||||
this.activityId = ko.observable<string>();
|
||||
this.roundTrips = ko.observable<number>();
|
||||
this.toggleState = ko.observable<ToggleState>(ToggleState.Result);
|
||||
|
||||
this.monacoSettings = new ViewModels.MonacoEditorSettings("sql", false);
|
||||
|
||||
this.executeQueryButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return !!this.sqlQueryEditorContent() && this.sqlQueryEditorContent().length > 0;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this._isSaveQueriesEnabled = ko.computed<boolean>(() => {
|
||||
const container: ViewModels.Explorer = this.collection && this.collection.container;
|
||||
return (container && (container.isPreferredApiDocumentDB() || container.isPreferredApiGraph())) || false;
|
||||
});
|
||||
|
||||
this.saveQueryButton = {
|
||||
enabled: this._isSaveQueriesEnabled,
|
||||
visible: this._isSaveQueriesEnabled
|
||||
};
|
||||
|
||||
super.onTemplateReady((isTemplateReady: boolean) => {
|
||||
if (isTemplateReady) {
|
||||
const splitterBounds: SplitterBounds = {
|
||||
min: Constants.Queries.QueryEditorMinHeightRatio * window.innerHeight,
|
||||
max: $("#" + this.tabId).height() - Constants.Queries.QueryEditorMaxHeightRatio * window.innerHeight
|
||||
};
|
||||
this.splitter = new Splitter({
|
||||
splitterId: this.splitterId,
|
||||
leftId: this.queryEditorId,
|
||||
bounds: splitterBounds,
|
||||
direction: SplitterDirection.Horizontal
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.fetchNextPageButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
const allResultsMetadata = this.allResultsMetadata() || [];
|
||||
const numberOfResultsMetadata = allResultsMetadata.length;
|
||||
|
||||
if (numberOfResultsMetadata === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allResultsMetadata[numberOfResultsMetadata - 1].hasMoreResults) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this._buildCommandBarOptions();
|
||||
}
|
||||
|
||||
public onTabClick(): Q.Promise<any> {
|
||||
return super.onTabClick().then(() => {
|
||||
this.collection && this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Query);
|
||||
});
|
||||
}
|
||||
|
||||
public onExecuteQueryClick = (): Q.Promise<any> => {
|
||||
const sqlStatement: string = this.selectedContent() || this.sqlQueryEditorContent();
|
||||
this.sqlStatementToExecute(sqlStatement);
|
||||
this.allResultsMetadata([]);
|
||||
this.queryResults("");
|
||||
this._iterator = null;
|
||||
|
||||
return this._executeQueryDocumentsPage(0);
|
||||
};
|
||||
|
||||
public onLoadQueryClick = (): void => {
|
||||
this.collection && this.collection.container && this.collection.container.loadQueryPane.open();
|
||||
};
|
||||
|
||||
public onSaveQueryClick = (): void => {
|
||||
this.collection && this.collection.container && this.collection.container.saveQueryPane.open();
|
||||
};
|
||||
|
||||
public onSavedQueriesClick = (): void => {
|
||||
this.collection && this.collection.container && this.collection.container.browseQueriesPane.open();
|
||||
};
|
||||
|
||||
public onFetchNextPageClick(): Q.Promise<any> {
|
||||
const allResultsMetadata = (this.allResultsMetadata && this.allResultsMetadata()) || [];
|
||||
const metadata: ViewModels.QueryResultsMetadata = allResultsMetadata[allResultsMetadata.length - 1];
|
||||
const firstResultIndex: number = (metadata && Number(metadata.firstItemIndex)) || 1;
|
||||
const itemCount: number = (metadata && Number(metadata.itemCount)) || 0;
|
||||
|
||||
return this._executeQueryDocumentsPage(firstResultIndex + itemCount - 1);
|
||||
}
|
||||
|
||||
public onErrorDetailsClick = (src: any, event: MouseEvent): boolean => {
|
||||
this.collection && this.collection.container.expandConsole();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
public onErrorDetailsKeyPress = (src: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.onErrorDetailsClick(src, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public toggleResult(): void {
|
||||
this.toggleState(ToggleState.Result);
|
||||
this.queryResults.valueHasMutated(); // needed to refresh the json-editor component
|
||||
}
|
||||
|
||||
public toggleMetrics(): void {
|
||||
this.toggleState(ToggleState.QueryMetrics);
|
||||
}
|
||||
|
||||
public onToggleKeyDown = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.LeftArrow) {
|
||||
this.toggleResult();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
} else if (event.keyCode === Constants.KeyCodes.RightArrow) {
|
||||
this.toggleMetrics();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public togglesOnFocus(): void {
|
||||
const focusElement = document.getElementById("execute-query-toggles");
|
||||
focusElement && focusElement.focus();
|
||||
}
|
||||
|
||||
public isResultToggled(): boolean {
|
||||
return this.toggleState() === ToggleState.Result;
|
||||
}
|
||||
|
||||
public isMetricsToggled(): boolean {
|
||||
return this.toggleState() === ToggleState.QueryMetrics;
|
||||
}
|
||||
|
||||
public onDownloadQueryMetricsCsvClick = (source: any, event: MouseEvent): boolean => {
|
||||
this._downloadQueryMetricsCsvData();
|
||||
return false;
|
||||
};
|
||||
|
||||
public onDownloadQueryMetricsCsvKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || Constants.KeyCodes.Enter) {
|
||||
this._downloadQueryMetricsCsvData();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private _executeQueryDocumentsPage(firstItemIndex: number): Q.Promise<any> {
|
||||
this.errors([]);
|
||||
this.roundTrips(undefined);
|
||||
if (this._iterator == null) {
|
||||
const queryIteratorPromise = this._initIterator();
|
||||
return queryIteratorPromise.finally(() => this._queryDocumentsPage(firstItemIndex));
|
||||
}
|
||||
|
||||
return this._queryDocumentsPage(firstItemIndex);
|
||||
}
|
||||
|
||||
// TODO: Position and enable spinner when request is in progress
|
||||
private _queryDocumentsPage(firstItemIndex: number): Q.Promise<any> {
|
||||
this.isExecutionError(false);
|
||||
this._resetAggregateQueryMetrics();
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.ExecuteQuery, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
let options: any = {};
|
||||
options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey();
|
||||
|
||||
const queryDocuments = (firstItemIndex: number) =>
|
||||
this.documentClientUtility.queryDocumentsPage(
|
||||
this.collection && this.collection.id(),
|
||||
this._iterator,
|
||||
firstItemIndex,
|
||||
options
|
||||
);
|
||||
this.isExecuting(true);
|
||||
return QueryUtils.queryPagesUntilContentPresent(firstItemIndex, queryDocuments)
|
||||
.then(
|
||||
(queryResults: ViewModels.QueryResults) => {
|
||||
const allResultsMetadata = (this.allResultsMetadata && this.allResultsMetadata()) || [];
|
||||
const metadata: ViewModels.QueryResultsMetadata = allResultsMetadata[allResultsMetadata.length - 1];
|
||||
const resultsMetadata: ViewModels.QueryResultsMetadata = {
|
||||
hasMoreResults: queryResults.hasMoreResults,
|
||||
itemCount: queryResults.itemCount,
|
||||
firstItemIndex: queryResults.firstItemIndex,
|
||||
lastItemIndex: queryResults.lastItemIndex
|
||||
};
|
||||
this.allResultsMetadata.push(resultsMetadata);
|
||||
this.activityId(queryResults.activityId);
|
||||
this.roundTrips(queryResults.roundTrips);
|
||||
|
||||
this._updateQueryMetricsMap(queryResults.headers[Constants.HttpHeaders.queryMetrics]);
|
||||
|
||||
if (queryResults.itemCount == 0 && metadata != null && metadata.itemCount >= 0) {
|
||||
// we let users query for the next page because the SDK sometimes specifies there are more elements
|
||||
// even though there aren't any so we should not update the prior query results.
|
||||
return;
|
||||
}
|
||||
|
||||
const documents: any[] = queryResults.documents;
|
||||
const results = this.renderObjectForEditor(documents, null, 4);
|
||||
|
||||
const resultsDisplay: string =
|
||||
queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`;
|
||||
this.showingDocumentsDisplayText(resultsDisplay);
|
||||
this.requestChargeDisplayText(`${queryResults.requestCharge} RUs`);
|
||||
|
||||
if (!this.queryResults() && !results) {
|
||||
const appInsights: any = (<any>window).appInsights;
|
||||
const errorMessage: string = JSON.stringify({
|
||||
error: `Returned no results after query execution`,
|
||||
accountName: this.collection && this.collection.container.databaseAccount(),
|
||||
databaseName: this.collection && this.collection.databaseId,
|
||||
collectionName: this.collection && this.collection.id(),
|
||||
sqlQuery: this.sqlStatementToExecute(),
|
||||
hasMoreResults: resultsMetadata.hasMoreResults,
|
||||
itemCount: resultsMetadata.itemCount,
|
||||
responseHeaders: queryResults && queryResults.headers
|
||||
});
|
||||
Logger.logError(errorMessage, "QueryTab");
|
||||
appInsights && appInsights.trackTrace(errorMessage);
|
||||
}
|
||||
|
||||
this.queryResults(results);
|
||||
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.ExecuteQuery,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
(reason: any) => {
|
||||
this.isExecutionError(true);
|
||||
const parsedErrors = ErrorParserUtility.parse(reason);
|
||||
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.errors(errors);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.ExecuteQuery,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
document.getElementById("error-display").focus();
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
this.isExecuting(false);
|
||||
this.togglesOnFocus();
|
||||
});
|
||||
}
|
||||
|
||||
private _updateQueryMetricsMap(metricsMap: { [partitionKeyRange: string]: DataModels.QueryMetrics }): void {
|
||||
if (!metricsMap) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(metricsMap).forEach((key: string) => {
|
||||
this.queryMetrics().set(key, metricsMap[key]);
|
||||
});
|
||||
this.queryMetrics.valueHasMutated();
|
||||
}
|
||||
|
||||
private _aggregateQueryMetrics(metricsMap: HashMap<DataModels.QueryMetrics>): DataModels.QueryMetrics {
|
||||
if (!metricsMap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aggregatedMetrics: DataModels.QueryMetrics = this.aggregatedQueryMetrics();
|
||||
metricsMap.forEach((partitionKeyRangeId: string, queryMetrics: DataModels.QueryMetrics) => {
|
||||
if (queryMetrics) {
|
||||
aggregatedMetrics.documentLoadTime =
|
||||
queryMetrics.documentLoadTime &&
|
||||
this._normalize(queryMetrics.documentLoadTime.totalMilliseconds()) +
|
||||
this._normalize(aggregatedMetrics.documentLoadTime);
|
||||
aggregatedMetrics.documentWriteTime =
|
||||
queryMetrics.documentWriteTime &&
|
||||
this._normalize(queryMetrics.documentWriteTime.totalMilliseconds()) +
|
||||
this._normalize(aggregatedMetrics.documentWriteTime);
|
||||
aggregatedMetrics.indexHitDocumentCount =
|
||||
queryMetrics.indexHitDocumentCount &&
|
||||
this._normalize(queryMetrics.indexHitDocumentCount) +
|
||||
this._normalize(aggregatedMetrics.indexHitDocumentCount);
|
||||
aggregatedMetrics.outputDocumentCount =
|
||||
queryMetrics.outputDocumentCount &&
|
||||
this._normalize(queryMetrics.outputDocumentCount) + this._normalize(aggregatedMetrics.outputDocumentCount);
|
||||
aggregatedMetrics.outputDocumentSize =
|
||||
queryMetrics.outputDocumentSize &&
|
||||
this._normalize(queryMetrics.outputDocumentSize) + this._normalize(aggregatedMetrics.outputDocumentSize);
|
||||
aggregatedMetrics.indexLookupTime =
|
||||
queryMetrics.indexLookupTime &&
|
||||
this._normalize(queryMetrics.indexLookupTime.totalMilliseconds()) +
|
||||
this._normalize(aggregatedMetrics.indexLookupTime);
|
||||
aggregatedMetrics.retrievedDocumentCount =
|
||||
queryMetrics.retrievedDocumentCount &&
|
||||
this._normalize(queryMetrics.retrievedDocumentCount) +
|
||||
this._normalize(aggregatedMetrics.retrievedDocumentCount);
|
||||
aggregatedMetrics.retrievedDocumentSize =
|
||||
queryMetrics.retrievedDocumentSize &&
|
||||
this._normalize(queryMetrics.retrievedDocumentSize) +
|
||||
this._normalize(aggregatedMetrics.retrievedDocumentSize);
|
||||
aggregatedMetrics.vmExecutionTime =
|
||||
queryMetrics.vmExecutionTime &&
|
||||
this._normalize(queryMetrics.vmExecutionTime.totalMilliseconds()) +
|
||||
this._normalize(aggregatedMetrics.vmExecutionTime);
|
||||
aggregatedMetrics.totalQueryExecutionTime =
|
||||
queryMetrics.totalQueryExecutionTime &&
|
||||
this._normalize(queryMetrics.totalQueryExecutionTime.totalMilliseconds()) +
|
||||
this._normalize(aggregatedMetrics.totalQueryExecutionTime);
|
||||
|
||||
aggregatedMetrics.runtimeExecutionTimes.queryEngineExecutionTime =
|
||||
aggregatedMetrics.runtimeExecutionTimes &&
|
||||
this._normalize(queryMetrics.runtimeExecutionTimes.queryEngineExecutionTime.totalMilliseconds()) +
|
||||
this._normalize(aggregatedMetrics.runtimeExecutionTimes.queryEngineExecutionTime);
|
||||
aggregatedMetrics.runtimeExecutionTimes.systemFunctionExecutionTime =
|
||||
aggregatedMetrics.runtimeExecutionTimes &&
|
||||
this._normalize(queryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime.totalMilliseconds()) +
|
||||
this._normalize(aggregatedMetrics.runtimeExecutionTimes.systemFunctionExecutionTime);
|
||||
aggregatedMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime =
|
||||
aggregatedMetrics.runtimeExecutionTimes &&
|
||||
this._normalize(queryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime.totalMilliseconds()) +
|
||||
this._normalize(aggregatedMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime);
|
||||
}
|
||||
});
|
||||
|
||||
return aggregatedMetrics;
|
||||
}
|
||||
|
||||
public _downloadQueryMetricsCsvData(): void {
|
||||
const csvData: string = this._generateQueryMetricsCsvData();
|
||||
if (!csvData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.msSaveBlob) {
|
||||
// for IE and Edge
|
||||
navigator.msSaveBlob(
|
||||
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
|
||||
"PerPartitionQueryMetrics.csv"
|
||||
);
|
||||
} else {
|
||||
const downloadLink: HTMLAnchorElement = document.createElement("a");
|
||||
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
|
||||
downloadLink.target = "_self";
|
||||
downloadLink.download = "QueryMetricsPerPartition.csv";
|
||||
|
||||
// for some reason, FF displays the download prompt only when
|
||||
// the link is added to the dom so we add and remove it
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
downloadLink.remove();
|
||||
}
|
||||
}
|
||||
|
||||
protected _initIterator(): Q.Promise<MinimalQueryIterator> {
|
||||
const options: any = QueryTab.getIteratorOptions(this.collection);
|
||||
if (this._resourceTokenPartitionKey) {
|
||||
options.partitionKey = this._resourceTokenPartitionKey;
|
||||
}
|
||||
|
||||
return Q(
|
||||
this.documentClientUtility
|
||||
.queryDocuments(this.collection.databaseId, this.collection.id(), this.sqlStatementToExecute(), options)
|
||||
.then(iterator => (this._iterator = iterator))
|
||||
);
|
||||
}
|
||||
|
||||
public static getIteratorOptions(container: ViewModels.CollectionBase): any {
|
||||
let options: any = {};
|
||||
options.enableCrossPartitionQuery = HeadersUtility.shouldEnableCrossPartitionKey();
|
||||
return options;
|
||||
}
|
||||
|
||||
private _normalize(value: number): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private _resetAggregateQueryMetrics(): void {
|
||||
this.aggregatedQueryMetrics({
|
||||
clientSideMetrics: {},
|
||||
documentLoadTime: undefined,
|
||||
documentWriteTime: undefined,
|
||||
indexHitDocumentCount: undefined,
|
||||
outputDocumentCount: undefined,
|
||||
outputDocumentSize: undefined,
|
||||
indexLookupTime: undefined,
|
||||
retrievedDocumentCount: undefined,
|
||||
retrievedDocumentSize: undefined,
|
||||
vmExecutionTime: undefined,
|
||||
queryPreparationTimes: undefined,
|
||||
runtimeExecutionTimes: {
|
||||
queryEngineExecutionTime: undefined,
|
||||
systemFunctionExecutionTime: undefined,
|
||||
userDefinedFunctionExecutionTime: undefined
|
||||
},
|
||||
totalQueryExecutionTime: undefined
|
||||
});
|
||||
}
|
||||
|
||||
private _generateQueryMetricsCsvData(): string {
|
||||
if (!this.queryMetrics()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryMetrics: HashMap<DataModels.QueryMetrics> = this.queryMetrics();
|
||||
let csvData: string = "";
|
||||
const columnHeaders: string =
|
||||
[
|
||||
"Partition key range id",
|
||||
"Retrieved document count",
|
||||
"Retrieved document size (in bytes)",
|
||||
"Output document count",
|
||||
"Output document size (in bytes)",
|
||||
"Index hit document count",
|
||||
"Index lookup time (ms)",
|
||||
"Document load time (ms)",
|
||||
"Query engine execution time (ms)",
|
||||
"System function execution time (ms)",
|
||||
"User defined function execution time (ms)",
|
||||
"Document write time (ms)"
|
||||
].join(",") + "\n";
|
||||
csvData = csvData + columnHeaders;
|
||||
queryMetrics.forEach((partitionKeyRangeId: string, queryMetric: DataModels.QueryMetrics) => {
|
||||
const partitionKeyRangeData: string =
|
||||
[
|
||||
partitionKeyRangeId,
|
||||
queryMetric.retrievedDocumentCount,
|
||||
queryMetric.retrievedDocumentSize,
|
||||
queryMetric.outputDocumentCount,
|
||||
queryMetric.outputDocumentSize,
|
||||
queryMetric.indexHitDocumentCount,
|
||||
queryMetric.indexLookupTime && queryMetric.indexLookupTime.totalMilliseconds(),
|
||||
queryMetric.documentLoadTime && queryMetric.documentLoadTime.totalMilliseconds(),
|
||||
queryMetric.runtimeExecutionTimes &&
|
||||
queryMetric.runtimeExecutionTimes.queryEngineExecutionTime &&
|
||||
queryMetric.runtimeExecutionTimes.queryEngineExecutionTime.totalMilliseconds(),
|
||||
queryMetric.runtimeExecutionTimes &&
|
||||
queryMetric.runtimeExecutionTimes.systemFunctionExecutionTime &&
|
||||
queryMetric.runtimeExecutionTimes.systemFunctionExecutionTime.totalMilliseconds(),
|
||||
queryMetric.runtimeExecutionTimes &&
|
||||
queryMetric.runtimeExecutionTimes.userDefinedFunctionExecutionTime &&
|
||||
queryMetric.runtimeExecutionTimes.userDefinedFunctionExecutionTime.totalMilliseconds(),
|
||||
queryMetric.documentWriteTime && queryMetric.documentWriteTime.totalMilliseconds()
|
||||
].join(",") + "\n";
|
||||
csvData = csvData + partitionKeyRangeData;
|
||||
});
|
||||
|
||||
return csvData;
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const buttons: ViewModels.NavbarButtonConfig[] = [];
|
||||
if (this.executeQueryButton.visible()) {
|
||||
const label = this._executeQueryButtonTitle();
|
||||
buttons.push({
|
||||
iconSrc: ExecuteQueryIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onExecuteQueryClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.executeQueryButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.saveQueryButton.visible()) {
|
||||
const label = "Save Query";
|
||||
buttons.push({
|
||||
iconSrc: SaveQueryIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onSaveQueryClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveQueryButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private _buildCommandBarOptions(): void {
|
||||
ko.computed(() =>
|
||||
ko.toJSON([this.executeQueryButton.visible, this.executeQueryButton.enabled, this._executeQueryButtonTitle])
|
||||
).subscribe(() => this.updateNavbarWithTabsButtons());
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
}
|
||||
249
src/Explorer/Tabs/QueryTablesTab.html
Normal file
249
src/Explorer/Tabs/QueryTablesTab.html
Normal file
@@ -0,0 +1,249 @@
|
||||
<div
|
||||
class="tab-pane tableContainer"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: tabId
|
||||
}"
|
||||
role="tabpanel"
|
||||
>
|
||||
<!-- Tables Query Tab Query Builder - Start-->
|
||||
<div
|
||||
class="query-builder"
|
||||
data-bind="with: queryViewModel, attr: {
|
||||
id: queryViewModel.id
|
||||
}"
|
||||
>
|
||||
<!-- Tables Query Tab Errors - Start-->
|
||||
<div class="error-bar">
|
||||
<div class="error-message" aria-label="Error Message" data-bind="visible: hasQueryError">
|
||||
<span><img class="entity-error-Img" src="/error_red.svg"/></span>
|
||||
<span class="error-text" data-bind="text: queryErrorMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tables Query Tab Errors - End-->
|
||||
<!-- Tables Query Tab Query Text - Start-->
|
||||
<div class="query-editor-panel" data-bind="visible: isEditorActive">
|
||||
<div>
|
||||
<textarea
|
||||
class="query-editor-text"
|
||||
data-bind="textInput: queryText,
|
||||
css: { 'query-editor-text-invalid': hasQueryError },
|
||||
readOnly: true"
|
||||
name="query-editor"
|
||||
rows="5"
|
||||
cols="100"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tables Query Tab Query Text - End-->
|
||||
<!-- Tables Query Tab Query Helper - Start-->
|
||||
<div data-bind="visible: isHelperActive" style="padding-left:13px">
|
||||
<div class="clause-table" data-bind="with: queryBuilderViewModel ">
|
||||
<div class="scroll-box scrollable" id="scroll">
|
||||
<table class="clause-table">
|
||||
<thead>
|
||||
<tr class="clause-table-row">
|
||||
<th class="clause-table-cell header-background action-header">
|
||||
<span data-bind="text: actionLabel"></span>
|
||||
</th>
|
||||
<th class="clause-table-cell header-background group-control-header">
|
||||
<button
|
||||
type="button"
|
||||
data-bind="enable: canGroupClauses, attr:{title: groupSelectedClauses}, click: groupClauses"
|
||||
>
|
||||
<img class="and-or-svg" src="/And-Or.svg" alt="Group selected clauses" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="clause-table-cell header-background"><!-- Grouping indicator --></th>
|
||||
<th class="clause-table-cell header-background and-or-header">
|
||||
<span data-bind="text: andLabel"></span>
|
||||
</th>
|
||||
<th class="clause-table-cell header-background field-header">
|
||||
<span data-bind="text: fieldLabel"></span>
|
||||
</th>
|
||||
<th class="clause-table-cell header-background type-header">
|
||||
<span data-bind="text: dataTypeLabel"></span>
|
||||
</th>
|
||||
<th class="clause-table-cell header-background operator-header">
|
||||
<span data-bind="text: operatorLabel"></span>
|
||||
</th>
|
||||
<th class="clause-table-cell header-background value-header">
|
||||
<span data-bind="text: valueLabel"></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-bind="template: { name: 'queryClause-template', foreach: clauseArray, as: 'clause' }"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
class="addClause"
|
||||
role="button"
|
||||
data-bind="click: addNewClause, event: { keydown: onAddNewClauseKeyDown }, attr: { title: addNewClauseLine }"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="addClause-heading">
|
||||
<span class="clause-table addClause-title">
|
||||
<img
|
||||
class="addclauseProperty-Img"
|
||||
style="margin-bottom:5px;"
|
||||
src="/Add-property.svg"
|
||||
alt="Add new clause"
|
||||
/>
|
||||
<span style="margin-left:5px;" data-bind="text: addNewClauseLine"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tables Query Tab Query Helper - End-->
|
||||
<!-- Tables Query Tab Advanced Options - Start-->
|
||||
<div class="advanced-options-panel">
|
||||
<div class="advanced-heading">
|
||||
<span
|
||||
class="advanced-title"
|
||||
role="button"
|
||||
data-bind="click:toggleAdvancedOptions, event: { keydown: ontoggleAdvancedOptionsKeyDown }, attr:{ 'aria-expanded': isExpanded() ? 'true' : 'false' }"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- ko template: { ifnot: isExpanded} -->
|
||||
<div class="themed-images" type="text/html" id="ExpandChevronRight" data-bind="hasFocus: focusExpandIcon">
|
||||
<img class="imgiconwidth expand-triangle expand-triangle-right " src="/Triangle-right.svg" alt="toggle" />
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- ko template: { if: isExpanded} -->
|
||||
<div class="themed-images" type="text/html" id="ExpandChevronDown">
|
||||
<img class="imgiconwidth expand-triangle" src="/Triangle-down.svg" alt="toggle" />
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<span>Advanced Options</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="advanced-options" data-bind="visible: isExpanded">
|
||||
<div class="top">
|
||||
<span>Show top results:</span>
|
||||
<input
|
||||
class="top-input"
|
||||
type="number"
|
||||
data-bind="hasFocus: focusTopResult, textInput: topValue, attr: { title: topValueLimitMessage }"
|
||||
role="textbox"
|
||||
aria-label="Show top results"
|
||||
/>
|
||||
<div role="alert" aria-atomic="true" class="inline-div" data-bind="visible: isExceedingLimit">
|
||||
<img class="advanced-options-icon" src="/QueryBuilder/StatusWarning_16x.png" />
|
||||
<span data-bind="text: topValueLimitMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="select">
|
||||
<span> Select fields for query: </span>
|
||||
<div data-bind="visible: isSelected">
|
||||
<img class="advanced-options-icon" src="/QueryBuilder/QueryInformation_16x.png" />
|
||||
<span class="select-options-text" data-bind="text: selectMessage" />
|
||||
</div>
|
||||
<a
|
||||
class="select-options-link"
|
||||
data-bind="click: selectQueryOptions, event: { keydown: onselectQueryOptionsKeyDown }"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
>
|
||||
<span>Choose Columns... </span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tables Query Tab Advanced Options - End-->
|
||||
</div>
|
||||
<!-- Tables Query Tab Query Builder - End-->
|
||||
<div
|
||||
class="tablesQueryTab tableContainer"
|
||||
data-bind="with: tableEntityListViewModel, attr: {
|
||||
id: tableEntityListViewModel.id
|
||||
}"
|
||||
>
|
||||
<!-- Keyboard navigation - tabindex is required to make the table focusable. -->
|
||||
<table
|
||||
id="storageTable"
|
||||
class="storage azure-table show-gridlines"
|
||||
tabindex="0"
|
||||
data-bind="tableSource: items, tableSelection: selected"
|
||||
></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Script for each clause in the tables query builder -->
|
||||
<script type="text/html" id="queryClause-template">
|
||||
<tr class="clause-table-row">
|
||||
<td class="clause-table-cell action-column">
|
||||
<span class="entity-Add-Cancel" role="button" tabindex="0" data-bind="click: $parent.addClauseIndex.bind($data, $index()), event: { keydown: $parent.onAddClauseKeyDown.bind($data, $index()) }, attr:{title: $parent.insertNewFilterLine}">
|
||||
<img class="querybuilder-addpropertyImg" src="/Add-property.svg" alt="Add clause">
|
||||
</span>
|
||||
<span class="entity-Add-Cancel" role="button" tabindex="0" data-bind="hasFocus: isDeleteButtonFocused, click: $parent.deleteClause.bind($data, $index()), event: { keydown: $parent.onDeleteClauseKeyDown.bind($data, $index()) }, attr:{title: $parent.removeThisFilterLine}">
|
||||
<img class="querybuilder-cancelImg" src="/Entity_cancel.svg" alt="Delete clause">
|
||||
</span>
|
||||
</td>
|
||||
<td class="clause-table-cell group-control-column">
|
||||
<input type="checkbox" aria-label="And/Or" data-bind="checked: checkedForGrouping"/>
|
||||
</td>
|
||||
<td>
|
||||
<table class="group-indicator-table">
|
||||
<tbody>
|
||||
<tr data-bind="template: { name: 'groupIndicator-template', foreach: $parent.getClauseGroupViewModels($data), as: 'gi' }">
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td class="clause-table-cell and-or-column">
|
||||
<select class="clause-table-field and-or-column" data-bind="hasFocus: isAndOrFocused, options: $parent.clauseRules, value: and_or, visible: canAnd, attr:{ 'aria-label': and_or }">
|
||||
</select>
|
||||
</td>
|
||||
<td class="clause-table-cell field-column" data-bind="click: $parent.updateColumnOptions">
|
||||
<select class="clause-table-field field-column" data-bind="options: $parent.columnOptions, value: field, attr:{ 'aria-label': field }">
|
||||
</select>
|
||||
</td>
|
||||
<td class="clause-table-cell type-column">
|
||||
<select class="clause-table-field type-column" data-bind="
|
||||
options: $parent.edmTypes,
|
||||
enable: isTypeEditable,
|
||||
value: type,
|
||||
css: {'query-builder-isDisabled': !isTypeEditable()},
|
||||
attr: { 'aria-label': type }">
|
||||
</select>
|
||||
</td>
|
||||
<td class="clause-table-cell operator-column">
|
||||
<select class="clause-table-field operator-column" data-bind="
|
||||
options: $parent.operators,
|
||||
enable: isOperaterEditable,
|
||||
value: operator,
|
||||
css: {'query-builder-isDisabled': !isOperaterEditable()},
|
||||
attr: { 'aria-label': operator }">
|
||||
</select>
|
||||
</td>
|
||||
<td class="clause-table-cell value-column">
|
||||
<!-- ko template: {if: isValue} -->
|
||||
<input type="text" class="clause-table-field value-column" type="search" data-bind="textInput: value, attr: {'aria-label': $parent.valueLabel}" />
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko template: {if: isTimestamp} -->
|
||||
<select class="clause-table-field time-column" data-bind="options: $parent.timeOptions, value: timeValue">
|
||||
</select>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko template: {if: isCustomLastTimestamp} -->
|
||||
<input class="clause-table-field time-column" data-bind="value: customTimeValue, click: customTimestampDialog" />
|
||||
<!-- /ko -->
|
||||
<!-- ko template: {if: isCustomRangeTimestamp} -->
|
||||
<input class="clause-table-field time-column" type="datetime-local" step=1 data-bind="value: customTimeValue" />
|
||||
<!-- /ko -->
|
||||
</td>
|
||||
</tr>
|
||||
</script>
|
||||
|
||||
<!-- Script for each clause group in the tables query builder -->
|
||||
<script type="text/html" id="groupIndicator-template">
|
||||
<td class="group-indicator-column" data-bind="style: {backgroundColor: gi.backgroundColor, borderTop: gi.showTopBorder.peek() ? 'solid thin #CCCCCC' : 'none', borderLeft: gi.showLeftBorder.peek() ? 'solid thin #CCCCCC' : 'none', borderBottom: gi.showBottomBorder.peek() ? 'solid thin #CCCCCC' : gi.borderBackgroundColor}">
|
||||
<!-- ko template: {if: gi.canUngroup} -->
|
||||
<button type="button" data-bind="click: ungroupClauses, attr: {title: ungroupClausesLabel}">
|
||||
<img src="/QueryBuilder/UngroupClause_16x.png" alt="Ungroup clauses"/>
|
||||
</button>
|
||||
<!-- /ko -->
|
||||
</td>
|
||||
</script>
|
||||
278
src/Explorer/Tabs/QueryTablesTab.ts
Normal file
278
src/Explorer/Tabs/QueryTablesTab.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
import TableEntityListViewModel from "../Tables/DataTable/TableEntityListViewModel";
|
||||
import QueryViewModel from "../Tables/QueryBuilder/QueryViewModel";
|
||||
import TableCommands from "../Tables/DataTable/TableCommands";
|
||||
import { TableDataClient } from "../Tables/TableDataClient";
|
||||
|
||||
import QueryBuilderIcon from "../../../images/Query-Builder.svg";
|
||||
import QueryTextIcon from "../../../images/Query-Text.svg";
|
||||
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";
|
||||
import AddEntityIcon from "../../../images/AddEntity.svg";
|
||||
import EditEntityIcon from "../../../images/Edit-entity.svg";
|
||||
import DeleteEntitiesIcon from "../../../images/DeleteEntities.svg";
|
||||
|
||||
// Will act as table explorer class
|
||||
export default class QueryTablesTab extends TabsBase {
|
||||
public collection: ViewModels.Collection;
|
||||
public tableEntityListViewModel = ko.observable<TableEntityListViewModel>();
|
||||
public queryViewModel = ko.observable<QueryViewModel>();
|
||||
public tableCommands: TableCommands;
|
||||
public tableDataClient: TableDataClient;
|
||||
|
||||
public queryText = ko.observable("PartitionKey eq 'partitionKey1'"); // Start out with an example they can modify
|
||||
public selectedQueryText = ko.observable("").extend({ notify: "always" });
|
||||
|
||||
public executeQueryButton: ViewModels.Button;
|
||||
public addEntityButton: ViewModels.Button;
|
||||
public editEntityButton: ViewModels.Button;
|
||||
public deleteEntityButton: ViewModels.Button;
|
||||
public queryBuilderButton: ViewModels.Button;
|
||||
public queryTextButton: ViewModels.Button;
|
||||
public container: ViewModels.Explorer;
|
||||
|
||||
constructor(options: ViewModels.TabOptions) {
|
||||
super(options);
|
||||
|
||||
this.container = options.collection && options.collection.container;
|
||||
this.tableCommands = new TableCommands(this.container);
|
||||
this.tableDataClient = this.container.tableDataClient;
|
||||
this.tableEntityListViewModel(new TableEntityListViewModel(this.tableCommands, this));
|
||||
this.tableEntityListViewModel().queryTablesTab = this;
|
||||
this.queryViewModel(new QueryViewModel(this));
|
||||
const sampleQuerySubscription = this.tableEntityListViewModel().items.subscribe(() => {
|
||||
if (this.tableEntityListViewModel().items().length > 0 && this.container.isPreferredApiTable()) {
|
||||
this.queryViewModel()
|
||||
.queryBuilderViewModel()
|
||||
.setExample();
|
||||
}
|
||||
sampleQuerySubscription.dispose();
|
||||
});
|
||||
|
||||
this.executeQueryButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.queryBuilderButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
}),
|
||||
|
||||
isSelected: ko.computed<boolean>(() => {
|
||||
return this.queryViewModel() ? this.queryViewModel().isHelperActive() : false;
|
||||
})
|
||||
};
|
||||
|
||||
this.queryTextButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
}),
|
||||
|
||||
isSelected: ko.computed<boolean>(() => {
|
||||
return this.queryViewModel() ? this.queryViewModel().isEditorActive() : false;
|
||||
})
|
||||
};
|
||||
|
||||
this.addEntityButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.editEntityButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return this.tableCommands.isEnabled(
|
||||
TableCommands.editEntityCommand,
|
||||
this.tableEntityListViewModel().selected()
|
||||
);
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.deleteEntityButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return this.tableCommands.isEnabled(
|
||||
TableCommands.deleteEntitiesCommand,
|
||||
this.tableEntityListViewModel().selected()
|
||||
);
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.buildCommandBarOptions();
|
||||
}
|
||||
|
||||
public onExecuteQueryClick = (): Q.Promise<any> => {
|
||||
this.queryViewModel().runQuery();
|
||||
return null;
|
||||
};
|
||||
|
||||
public onQueryBuilderClick = (): Q.Promise<any> => {
|
||||
this.queryViewModel().selectHelper();
|
||||
return null;
|
||||
};
|
||||
|
||||
public onQueryTextClick = (): Q.Promise<any> => {
|
||||
this.queryViewModel().selectEditor();
|
||||
return null;
|
||||
};
|
||||
|
||||
public onAddEntityClick = (): Q.Promise<any> => {
|
||||
this.container.addTableEntityPane.tableViewModel = this.tableEntityListViewModel();
|
||||
this.container.addTableEntityPane.open();
|
||||
return null;
|
||||
};
|
||||
|
||||
public onEditEntityClick = (): Q.Promise<any> => {
|
||||
this.tableCommands.editEntityCommand(this.tableEntityListViewModel());
|
||||
return null;
|
||||
};
|
||||
|
||||
public onDeleteEntityClick = (): Q.Promise<any> => {
|
||||
this.tableCommands.deleteEntitiesCommand(this.tableEntityListViewModel());
|
||||
return null;
|
||||
};
|
||||
|
||||
public onActivate(): Q.Promise<any> {
|
||||
return super.onActivate().then(() => {
|
||||
const columns =
|
||||
!!this.tableEntityListViewModel() &&
|
||||
!!this.tableEntityListViewModel().table &&
|
||||
this.tableEntityListViewModel().table.columns;
|
||||
if (!!columns) {
|
||||
columns.adjust();
|
||||
$(window).resize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const buttons: ViewModels.NavbarButtonConfig[] = [];
|
||||
if (this.queryBuilderButton.visible()) {
|
||||
const label = this.container.isPreferredApiCassandra() ? "CQL Query Builder" : "Query Builder";
|
||||
buttons.push({
|
||||
iconSrc: QueryBuilderIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onQueryBuilderClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.queryBuilderButton.enabled(),
|
||||
isSelected: this.queryBuilderButton.isSelected()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.queryTextButton.visible()) {
|
||||
const label = this.container.isPreferredApiCassandra() ? "CQL Query Text" : "Query Text";
|
||||
buttons.push({
|
||||
iconSrc: QueryTextIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onQueryTextClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.queryTextButton.enabled(),
|
||||
isSelected: this.queryTextButton.isSelected()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.executeQueryButton.visible()) {
|
||||
const label = "Run Query";
|
||||
buttons.push({
|
||||
iconSrc: ExecuteQueryIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onExecuteQueryClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.executeQueryButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.addEntityButton.visible()) {
|
||||
const label = this.container.isPreferredApiCassandra() ? "Add Row" : "Add Entity";
|
||||
buttons.push({
|
||||
iconSrc: AddEntityIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onAddEntityClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: true,
|
||||
disabled: !this.addEntityButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.editEntityButton.visible()) {
|
||||
const label = this.container.isPreferredApiCassandra() ? "Edit Row" : "Edit Entity";
|
||||
buttons.push({
|
||||
iconSrc: EditEntityIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onEditEntityClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: true,
|
||||
disabled: !this.editEntityButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.deleteEntityButton.visible()) {
|
||||
const label = this.container.isPreferredApiCassandra() ? "Delete Rows" : "Delete Entities";
|
||||
buttons.push({
|
||||
iconSrc: DeleteEntitiesIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onDeleteEntityClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: true,
|
||||
disabled: !this.deleteEntityButton.enabled()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
ko.computed(() =>
|
||||
ko.toJSON([
|
||||
this.queryBuilderButton.visible,
|
||||
this.queryBuilderButton.enabled,
|
||||
this.queryTextButton.visible,
|
||||
this.queryTextButton.enabled,
|
||||
this.executeQueryButton.visible,
|
||||
this.executeQueryButton.enabled,
|
||||
this.addEntityButton.visible,
|
||||
this.addEntityButton.enabled,
|
||||
this.editEntityButton.visible,
|
||||
this.editEntityButton.enabled,
|
||||
this.deleteEntityButton.visible,
|
||||
this.deleteEntityButton.enabled
|
||||
])
|
||||
).subscribe(() => this.updateNavbarWithTabsButtons());
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
}
|
||||
385
src/Explorer/Tabs/ScriptTabBase.ts
Normal file
385
src/Explorer/Tabs/ScriptTabBase.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
import editable from "../../Common/EditableUtility";
|
||||
import * as monaco from "monaco-editor";
|
||||
import SaveIcon from "../../../images/save-cosmos.svg";
|
||||
import DiscardIcon from "../../../images/discard.svg";
|
||||
|
||||
export default abstract class ScriptTabBase extends TabsBase
|
||||
implements ViewModels.ScriptTab, ViewModels.WaitsForTemplate {
|
||||
public ariaLabel: ko.Observable<string>;
|
||||
public editorState: ko.Observable<ViewModels.ScriptEditorState>;
|
||||
public id: ViewModels.Editable<string>;
|
||||
public editorContent: ViewModels.Editable<string>;
|
||||
public editorId: string;
|
||||
public editor: ko.Observable<monaco.editor.IStandaloneCodeEditor>;
|
||||
public executeButton: ViewModels.Button;
|
||||
public saveButton: ViewModels.Button;
|
||||
public updateButton: ViewModels.Button;
|
||||
public discardButton: ViewModels.Button;
|
||||
public deleteButton: ViewModels.Button;
|
||||
public errors: ko.ObservableArray<ViewModels.QueryError>;
|
||||
public statusMessge: ko.Observable<string>;
|
||||
public statusIcon: ko.Observable<string>;
|
||||
public formFields: ko.ObservableArray<ViewModels.Editable<any>>;
|
||||
public formIsValid: ko.Computed<boolean>;
|
||||
public formIsDirty: ko.Computed<boolean>;
|
||||
public isNew: ko.Observable<boolean>;
|
||||
public resource: ko.Observable<DataModels.Script>;
|
||||
public isTemplateReady: ko.Observable<boolean>;
|
||||
protected _partitionKey: DataModels.PartitionKey;
|
||||
|
||||
constructor(options: ViewModels.ScriptTabOption) {
|
||||
super(options);
|
||||
this._partitionKey = options.partitionKey;
|
||||
this.isNew = ko.observable(options.isNew);
|
||||
this.resource = ko.observable(options.resource);
|
||||
this.isTemplateReady = ko.observable<boolean>(false);
|
||||
this.isTemplateReady.subscribe((isTemplateReady: boolean) => {
|
||||
if (isTemplateReady) {
|
||||
// setTimeout is needed as creating the edtior manipulates the dom directly and expects
|
||||
// Knockout to have completed all of the initial bindings for the component
|
||||
setTimeout(() => this._createBodyEditor(), Constants.ClientDefaults.waitForDOMElementMs);
|
||||
}
|
||||
});
|
||||
|
||||
this.editorId = `editor_${this.tabId}`;
|
||||
this.ariaLabel = ko.observable<string>();
|
||||
if (this.isNew()) {
|
||||
this.editorState = ko.observable(ViewModels.ScriptEditorState.newInvalid);
|
||||
} else {
|
||||
this.editorState = ko.observable(ViewModels.ScriptEditorState.exisitingNoEdits);
|
||||
}
|
||||
|
||||
this.id = editable.observable<string>();
|
||||
this.id.validations([ScriptTabBase._isValidId]);
|
||||
|
||||
this.editorContent = editable.observable<string>();
|
||||
this.editorContent.validations([ScriptTabBase._isNotEmpty]);
|
||||
|
||||
this.formFields = ko.observableArray([this.id, this.editorContent]);
|
||||
|
||||
this._setBaselines();
|
||||
|
||||
this.id.editableIsValid.subscribe(isValid => {
|
||||
const currentState = this.editorState();
|
||||
switch (currentState) {
|
||||
case ViewModels.ScriptEditorState.newValid:
|
||||
case ViewModels.ScriptEditorState.newInvalid:
|
||||
if (isValid) {
|
||||
this.editorState(ViewModels.ScriptEditorState.newValid);
|
||||
} else {
|
||||
this.editorState(ViewModels.ScriptEditorState.newInvalid);
|
||||
}
|
||||
break;
|
||||
case ViewModels.ScriptEditorState.exisitingDirtyInvalid:
|
||||
case ViewModels.ScriptEditorState.exisitingDirtyValid:
|
||||
if (isValid) {
|
||||
this.editorState(ViewModels.ScriptEditorState.exisitingDirtyValid);
|
||||
} else {
|
||||
this.editorState(ViewModels.ScriptEditorState.exisitingDirtyInvalid);
|
||||
}
|
||||
break;
|
||||
case ViewModels.ScriptEditorState.exisitingDirtyValid:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.editor = ko.observable<monaco.editor.IStandaloneCodeEditor>();
|
||||
|
||||
this.formIsValid = ko.computed<boolean>(() => {
|
||||
const formIsValid: boolean = this.formFields().every(field => {
|
||||
return field.editableIsValid();
|
||||
});
|
||||
|
||||
return formIsValid;
|
||||
});
|
||||
|
||||
this.formIsDirty = ko.computed<boolean>(() => {
|
||||
const formIsDirty: boolean = this.formFields().some(field => {
|
||||
return field.editableIsDirty();
|
||||
});
|
||||
|
||||
return formIsDirty;
|
||||
});
|
||||
|
||||
this.saveButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
if (!this.formIsValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.formIsDirty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return this.isNew();
|
||||
})
|
||||
};
|
||||
|
||||
this.updateButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
if (!this.formIsValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.formIsDirty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return !this.isNew();
|
||||
})
|
||||
};
|
||||
|
||||
this.discardButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return this.formIsDirty();
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.deleteButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return !this.isNew();
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
this.executeButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return !this.isNew() && !this.formIsDirty() && this.formIsValid();
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private _setBaselines() {
|
||||
const resource = this.resource();
|
||||
this.id.setBaseline(resource.id);
|
||||
this.editorContent.setBaseline(resource.body);
|
||||
}
|
||||
|
||||
public setBaselines() {
|
||||
this._setBaselines();
|
||||
}
|
||||
|
||||
public onTabClick(): Q.Promise<any> {
|
||||
return super.onTabClick().then(() => {
|
||||
if (this.isNew()) {
|
||||
this.collection.selectedSubnodeKind(this.tabKind);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public abstract onSaveClick: () => Q.Promise<any>;
|
||||
public abstract onUpdateClick: () => Q.Promise<any>;
|
||||
|
||||
public onDiscard = (): Q.Promise<any> => {
|
||||
this.setBaselines();
|
||||
const original = this.editorContent.getEditableOriginalValue();
|
||||
const editorModel = this.editor() && this.editor().getModel();
|
||||
editorModel && editorModel.setValue(original);
|
||||
|
||||
return Q();
|
||||
};
|
||||
|
||||
public onSaveOrUpdateClick(): Q.Promise<any> {
|
||||
if (this.saveButton.visible()) {
|
||||
return this.onSaveClick();
|
||||
} else if (this.updateButton.visible()) {
|
||||
return this.onUpdateClick();
|
||||
}
|
||||
|
||||
return Q();
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const buttons: ViewModels.NavbarButtonConfig[] = [];
|
||||
const label = "Save";
|
||||
if (this.saveButton.visible()) {
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onSaveClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.updateButton.visible()) {
|
||||
const label = "Update";
|
||||
buttons.push({
|
||||
iconSrc: SaveIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onUpdateClick,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.updateButton.enabled()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.discardButton.visible()) {
|
||||
const label = "Discard";
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onDiscard,
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.discardButton.enabled()
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
ko.computed(() =>
|
||||
ko.toJSON([
|
||||
this.saveButton.visible,
|
||||
this.saveButton.enabled,
|
||||
this.updateButton.visible,
|
||||
this.updateButton.enabled,
|
||||
this.discardButton.visible,
|
||||
this.discardButton.enabled
|
||||
])
|
||||
).subscribe(() => this.updateNavbarWithTabsButtons());
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
|
||||
private static _isValidId(id: string): boolean {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const invalidStartCharacters = /^[/?#\\]/;
|
||||
if (invalidStartCharacters.test(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const invalidMiddleCharacters = /^.+[/?#\\]/;
|
||||
if (invalidMiddleCharacters.test(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const invalidEndCharacters = /.*[/?#\\ ]$/;
|
||||
if (invalidEndCharacters.test(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static _isNotEmpty(value: string): boolean {
|
||||
return !!value;
|
||||
}
|
||||
|
||||
private static _toSeverity(severity: string): monaco.MarkerSeverity {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "error":
|
||||
return monaco.MarkerSeverity.Error;
|
||||
case "warning":
|
||||
return monaco.MarkerSeverity.Warning;
|
||||
case "info":
|
||||
return monaco.MarkerSeverity.Info;
|
||||
case "ignore":
|
||||
default:
|
||||
return monaco.MarkerSeverity.Hint;
|
||||
}
|
||||
}
|
||||
|
||||
private static _toEditorPosition(target: number, lines: string[]): ViewModels.EditorPosition {
|
||||
let cursor: number = 0;
|
||||
let previousCursor: number = 0;
|
||||
let i: number = 0;
|
||||
while (target > cursor + lines[i].length) {
|
||||
cursor += lines[i].length + 2;
|
||||
i++;
|
||||
}
|
||||
|
||||
const editorPosition: ViewModels.EditorPosition = {
|
||||
line: i + 1,
|
||||
column: target - cursor + 1
|
||||
};
|
||||
|
||||
return editorPosition;
|
||||
}
|
||||
|
||||
protected _createBodyEditor() {
|
||||
const id = this.editorId;
|
||||
const container = document.getElementById(id);
|
||||
const options = {
|
||||
value: this.editorContent(),
|
||||
language: "javascript",
|
||||
readOnly: false,
|
||||
ariaLabel: this.ariaLabel()
|
||||
};
|
||||
|
||||
container.innerHTML = "";
|
||||
|
||||
const editor = monaco.editor.create(container, options);
|
||||
this.editor(editor);
|
||||
|
||||
const editorModel = editor.getModel();
|
||||
editorModel.onDidChangeContent(this._onBodyContentChange.bind(this));
|
||||
}
|
||||
|
||||
private _onBodyContentChange(e: monaco.editor.IModelContentChangedEvent) {
|
||||
const editorModel = this.editor().getModel();
|
||||
this.editorContent(editorModel.getValue());
|
||||
}
|
||||
|
||||
private _setModelMarkers(errors: ViewModels.QueryError[]) {
|
||||
const markers: monaco.editor.IMarkerData[] = errors.map(e => this._toMarker(e));
|
||||
const editorModel = this.editor().getModel();
|
||||
monaco.editor.setModelMarkers(editorModel, this.tabId, markers);
|
||||
}
|
||||
|
||||
private _resetModelMarkers() {
|
||||
const queryEditorModel = this.editor().getModel();
|
||||
monaco.editor.setModelMarkers(queryEditorModel, this.tabId, []);
|
||||
}
|
||||
|
||||
private _toMarker(error: ViewModels.QueryError): monaco.editor.IMarkerData {
|
||||
const editorModel = this.editor().getModel();
|
||||
const lines: string[] = editorModel.getLinesContent();
|
||||
const start: ViewModels.EditorPosition = ScriptTabBase._toEditorPosition(Number(error.start), lines);
|
||||
const end: ViewModels.EditorPosition = ScriptTabBase._toEditorPosition(Number(error.end), lines);
|
||||
|
||||
return {
|
||||
severity: ScriptTabBase._toSeverity(error.severity),
|
||||
message: error.message,
|
||||
startLineNumber: start.line,
|
||||
startColumn: start.column,
|
||||
endLineNumber: end.line,
|
||||
endColumn: end.column,
|
||||
code: error.code
|
||||
};
|
||||
}
|
||||
}
|
||||
755
src/Explorer/Tabs/SettingsTab.html
Normal file
755
src/Explorer/Tabs/SettingsTab.html
Normal file
@@ -0,0 +1,755 @@
|
||||
<div
|
||||
class="tab-pane flexContainer"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: tabId
|
||||
},
|
||||
visible: isActive"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div class="warningErrorContainer scaleWarningContainer" data-bind="visible: shouldShowStatusBar">
|
||||
<div>
|
||||
<div class="warningErrorContent" data-bind="visible: shouldShowNotificationStatusPrompt">
|
||||
<span><img src="/info_color.svg" alt="Info"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer" data-bind="html: notificationStatusInfo"></span>
|
||||
</div>
|
||||
<div class="warningErrorContent" data-bind="visible: !shouldShowNotificationStatusPrompt()">
|
||||
<span><img src="/warning.svg" alt="Warning"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer" data-bind="html: warningMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabForm scaleSettingScrollable">
|
||||
<!-- ko if: shouldShowKeyspaceSharedThroughputMessage -->
|
||||
<div>This table shared throughput is configured at the keyspace</div>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko ifnot: hasDatabaseSharedThroughput -->
|
||||
<div>
|
||||
<div
|
||||
class="scaleDivison"
|
||||
data-bind="click:toggleScale, event: { keypress: onScaleKeyPress }, attr:{ 'aria-expanded': scaleExpanded() ? 'true' : 'false' }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Scale"
|
||||
aria-controls="scaleRegion"
|
||||
>
|
||||
<span class="themed-images" type="text/html" id="ExpandChevronRightScale" data-bind="visible: !scaleExpanded()">
|
||||
<img
|
||||
class="imgiconwidth ssExpandCollapseIcon ssCollapseIcon "
|
||||
src="/Triangle-right.svg"
|
||||
alt="Show scale properties"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="themed-images" type="text/html" id="ExpandChevronDownScale" data-bind="visible: scaleExpanded">
|
||||
<img class="imgiconwidth ssExpandCollapseIcon " src="/Triangle-down.svg" alt="Hide scale properties" />
|
||||
</span>
|
||||
|
||||
<span class="scaleSettingTitle">Scale</span>
|
||||
</div>
|
||||
|
||||
<div class="ssTextAllignment" data-bind="visible: scaleExpanded" id="scaleRegion">
|
||||
<!-- ko ifnot: isAutoScaleEnabled -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && !hasAutoPilotV2FeatureFlag() -->
|
||||
<throughput-input-autopilot-v3
|
||||
params="{
|
||||
testId: testId,
|
||||
class: 'scaleForm dirty',
|
||||
value: throughput,
|
||||
minimum: minRUs,
|
||||
maximum: maxRUThroughputInputLimit,
|
||||
isEnabled: !hasDatabaseSharedThroughput(),
|
||||
canExceedMaximumValue: canThroughputExceedMaximumValue,
|
||||
label: throughputTitle,
|
||||
ariaLabel: throughputAriaLabel,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost,
|
||||
throughputAutoPilotRadioId: throughputAutoPilotRadioId,
|
||||
throughputProvisionedRadioId: throughputProvisionedRadioId,
|
||||
throughputModeRadioName: throughputModeRadioName,
|
||||
showAutoPilot: userCanChangeProvisioningTypes,
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
maxAutoPilotThroughputSet: autoPilotThroughput,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
overrideWithAutoPilotSettings: overrideWithAutoPilotSettings,
|
||||
overrideWithProvisionedThroughputSettings: overrideWithProvisionedThroughputSettings
|
||||
}"
|
||||
>
|
||||
</throughput-input-autopilot-v3>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && hasAutoPilotV2FeatureFlag() -->
|
||||
<throughput-input
|
||||
params="{
|
||||
testId: testId,
|
||||
class: 'scaleForm dirty',
|
||||
value: throughput,
|
||||
minimum: minRUs,
|
||||
maximum: maxRUThroughputInputLimit,
|
||||
isEnabled: !hasDatabaseSharedThroughput(),
|
||||
canExceedMaximumValue: canThroughputExceedMaximumValue,
|
||||
label: throughputTitle,
|
||||
ariaLabel: throughputAriaLabel,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost,
|
||||
throughputAutoPilotRadioId: throughputAutoPilotRadioId,
|
||||
throughputProvisionedRadioId: throughputProvisionedRadioId,
|
||||
throughputModeRadioName: throughputModeRadioName,
|
||||
showAutoPilot: userCanChangeProvisioningTypes,
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
autoPilotTiersList: autoPilotTiersList,
|
||||
selectedAutoPilotTier: selectedAutoPilotTier,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue
|
||||
}"
|
||||
>
|
||||
</throughput-input>
|
||||
<!-- /ko -->
|
||||
|
||||
<div
|
||||
class="storageCapacityTitle throughputStorageValue"
|
||||
data-bind="html: storageCapacityTitle, visible: !isAutoPilotSelected()"
|
||||
></div>
|
||||
<!-- /ko -->
|
||||
|
||||
<div data-bind="visible: rupmVisible">
|
||||
<div class="formTitle">RU/m</div>
|
||||
<div class="tabs" aria-label="RU/m">
|
||||
<div class="tab">
|
||||
<label
|
||||
data-bind="
|
||||
attr:{
|
||||
for: rupmOnId
|
||||
},
|
||||
css: {
|
||||
dirty: rupm.editableIsDirty,
|
||||
selectedRadio: rupm() === 'on',
|
||||
unselectedRadio: rupm() !== 'on'
|
||||
}"
|
||||
>On</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="rupm"
|
||||
value="on"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: rupmOnId
|
||||
},
|
||||
checked: rupm"
|
||||
/>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<label
|
||||
data-bind="
|
||||
attr:{
|
||||
for: rupmOffId
|
||||
},
|
||||
css: {
|
||||
dirty: rupm.editableIsDirty,
|
||||
selectedRadio: rupm() === 'off',
|
||||
unselectedRadio: rupm() !== 'off'
|
||||
}"
|
||||
>Off</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="rupm"
|
||||
value="off"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: rupmOffId
|
||||
},
|
||||
checked: rupm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Replace link with call to the Azure Support blade -->
|
||||
<div data-bind="visible: isAutoScaleEnabled">
|
||||
<div class="autoScaleThroughputTitle">Throughput (RU/s)</div>
|
||||
<input
|
||||
class="formReadOnly collid-white"
|
||||
readonly
|
||||
aria-label="Throughput input"
|
||||
data-bind="textInput: throughput"
|
||||
/>
|
||||
<div class="autoScaleDescription">
|
||||
Your account has custom settings that prevents setting throughput at the container level. Please work with
|
||||
your Cosmos DB engineering team point of contact to make changes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
|
||||
<div data-bind="visible: hasConflictResolution">
|
||||
<div
|
||||
class="formTitle"
|
||||
data-bind="click:toggleConflictResolution, event: { keypress: onConflictResolutionKeyPress }, attr:{ 'aria-expanded': conflictResolutionExpanded() ? 'true' : 'false' }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Conflict Resolution"
|
||||
aria-controls="conflictResolutionRegion"
|
||||
>
|
||||
<span
|
||||
class="themed-images"
|
||||
type="text/html"
|
||||
id="ExpandChevronRightConflictResolution"
|
||||
data-bind="visible: !conflictResolutionExpanded()"
|
||||
>
|
||||
<img
|
||||
class="imgiconwidth ssExpandCollapseIcon ssCollapseIcon"
|
||||
src="/Triangle-right.svg"
|
||||
alt="Show conflict resolution"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="themed-images"
|
||||
type="text/html"
|
||||
id="ExpandChevronDownConflictResolution"
|
||||
data-bind="visible: conflictResolutionExpanded"
|
||||
>
|
||||
<img class="imgiconwidth ssExpandCollapseIcon" src="/Triangle-down.svg" alt="Show conflict resolution" />
|
||||
</span>
|
||||
<span class="scaleSettingTitle">Conflict resolution</span>
|
||||
</div>
|
||||
<div id="conflictResolutionRegion" class="ssTextAllignment" data-bind="visible: conflictResolutionExpanded">
|
||||
<div class="formTitle">Mode</div>
|
||||
<div class="tabs" aria-label="Mode" role="radiogroup">
|
||||
<div class="tab">
|
||||
<label
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: conflictResolutionPolicyModeLWW,
|
||||
'aria-checked': conflictResolutionPolicyMode() !== 'Custom' ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: conflictResolutionPolicyMode.editableIsDirty,
|
||||
selectedRadio: conflictResolutionPolicyMode() === 'LastWriterWins',
|
||||
unselectedRadio: conflictResolutionPolicyMode() !== 'LastWriterWins'
|
||||
},
|
||||
event: {
|
||||
keypress: onConflictResolutionLWWKeyPress
|
||||
}"
|
||||
>Last Write Wins (default)</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="conflictresolution"
|
||||
value="LastWriterWins"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: conflictResolutionPolicyModeLWW
|
||||
},
|
||||
checked: conflictResolutionPolicyMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tab">
|
||||
<label
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: conflictResolutionPolicyModeCustom,
|
||||
'aria-checked': conflictResolutionPolicyMode() === 'Custom' ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: conflictResolutionPolicyMode.editableIsDirty,
|
||||
selectedRadio: conflictResolutionPolicyMode() === 'Custom',
|
||||
unselectedRadio: conflictResolutionPolicyMode() !== 'Custom'
|
||||
},
|
||||
event: {
|
||||
keypress: onConflictResolutionCustomKeyPress
|
||||
}"
|
||||
>Merge Procedure (custom)</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="conflictresolution"
|
||||
value="Custom"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: conflictResolutionPolicyModeCustom
|
||||
},
|
||||
checked: conflictResolutionPolicyMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div data-bind="visible: conflictResolutionPolicyMode() === 'LastWriterWins'">
|
||||
<p class="formTitle">
|
||||
Conflict Resolver Property
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext infoTooltipWidth"
|
||||
>Gets or sets the name of a integer property in your documents which is used for the Last Write Wins
|
||||
(LWW) based conflict resolution scheme. By default, the system uses the system defined timestamp
|
||||
property, _ts to decide the winner for the conflicting versions of the document. Specify your own
|
||||
integer property if you want to override the default timestamp based conflict resolution.</span
|
||||
>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Document path for conflict resolution"
|
||||
data-bind="
|
||||
css: {
|
||||
dirty: conflictResolutionPolicyPath.editableIsDirty
|
||||
},
|
||||
textInput: conflictResolutionPolicyPath,
|
||||
enable: conflictResolutionPolicyMode() === 'LastWriterWins'"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div data-bind="visible: conflictResolutionPolicyMode() === 'Custom'">
|
||||
<p class="formTitle">
|
||||
Stored procedure
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext infoTooltipWidth"
|
||||
>Gets or sets the name of a stored procedure (aka merge procedure) for resolving the conflicts. You can
|
||||
write application defined logic to determine the winner of the conflicting versions of a document. The
|
||||
stored procedure will get executed transactionally, exactly once, on the server side. If you do not
|
||||
provide a stored procedure, the conflicts will be populated in the
|
||||
<a class="linkDarkBackground" href="https://aka.ms/dataexplorerconflics" target="_blank"
|
||||
>conflicts feed</a
|
||||
>. You can update/re-register the stored procedure at any time.</span
|
||||
>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Stored procedure name for conflict resolution"
|
||||
data-bind="
|
||||
css: {
|
||||
dirty: conflictResolutionPolicyProcedure.editableIsDirty
|
||||
},
|
||||
textInput: conflictResolutionPolicyProcedure,
|
||||
enable: conflictResolutionPolicyMode() === 'Custom'"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="formTitle"
|
||||
data-bind="click:toggleSettings, event: { keypress: onSettingsKeyPress }, attr:{ 'aria-expanded': settingsExpanded() ? 'true' : 'false' }, visible: shouldShowIndexingPolicyEditor() || ttlVisible()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Settings"
|
||||
aria-controls="settingsRegion"
|
||||
>
|
||||
<span
|
||||
class="themed-images"
|
||||
type="text/html"
|
||||
id="ExpandChevronRightSettings"
|
||||
data-bind="visible: !settingsExpanded() && !hasDatabaseSharedThroughput()"
|
||||
>
|
||||
<img class="imgiconwidth ssExpandCollapseIcon ssCollapseIcon" src="/Triangle-right.svg" alt="Show settings" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="themed-images"
|
||||
type="text/html"
|
||||
id="ExpandChevronDownSettings"
|
||||
data-bind="visible: settingsExpanded() && !hasDatabaseSharedThroughput()"
|
||||
>
|
||||
<img class="imgiconwidth ssExpandCollapseIcon" src="/Triangle-down.svg" alt="Show settings" />
|
||||
</span>
|
||||
<span class="scaleSettingTitle">Settings</span>
|
||||
</div>
|
||||
<div class="ssTextAllignment" data-bind="visible: settingsExpanded" id="settingsRegion">
|
||||
<div data-bind="visible: ttlVisible">
|
||||
<div class="formTitle">Time to Live</div>
|
||||
<div class="tabs disableFocusDefaults" aria-label="Time to Live" role="radiogroup">
|
||||
<div class="tab">
|
||||
<label
|
||||
class="ttlIndexingPolicyFocusElement"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: ttlOffId,
|
||||
'aria-checked': timeToLive() === 'off' ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: timeToLive.editableIsDirty,
|
||||
selectedRadio: timeToLive() === 'off',
|
||||
unselectedRadio: timeToLive() !== 'off'
|
||||
},
|
||||
event: {
|
||||
keypress: onTtlOffKeyPress
|
||||
},
|
||||
hasFocus: ttlOffFocused"
|
||||
>Off</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="ttl"
|
||||
value="off"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: ttlOffId
|
||||
},
|
||||
checked: timeToLive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tab">
|
||||
<label
|
||||
class="ttlIndexingPolicyFocusElement"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: ttlOnNoDefaultId,
|
||||
'aria-checked': timeToLive() === 'on-nodefault' ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: timeToLive.editableIsDirty,
|
||||
selectedRadio: timeToLive() === 'on-nodefault',
|
||||
unselectedRadio: timeToLive() !== 'on-nodefault'
|
||||
},
|
||||
event: {
|
||||
keypress: onTtlOnNoDefaultKeyPress
|
||||
},
|
||||
hasFocus: ttlOnDefaultFocused"
|
||||
>On (no default)</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="ttl"
|
||||
value="on-nodefault"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: ttlOnNoDefaultId
|
||||
},
|
||||
checked: timeToLive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tab">
|
||||
<label
|
||||
class="ttlIndexingPolicyFocusElement"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
for="ttl3"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: ttlOnId,
|
||||
'aria-checked': timeToLive() === 'on' ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: timeToLive.editableIsDirty,
|
||||
selectedRadio: timeToLive() === 'on',
|
||||
unselectedRadio: timeToLive() !== 'on'
|
||||
},
|
||||
event: {
|
||||
keypress: onTtlOnKeyPress
|
||||
},
|
||||
hasFocus: ttlOnFocused"
|
||||
>On</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="ttl"
|
||||
value="on"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: ttlOnId
|
||||
},
|
||||
checked: timeToLive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div data-bind="visible: timeToLive() === 'on'">
|
||||
<input
|
||||
class="dirtyTextbox"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="2147483647"
|
||||
aria-label="Time to live in seconds"
|
||||
data-bind="
|
||||
css: {
|
||||
dirty: timeToLive.editableIsDirty
|
||||
},
|
||||
textInput: timeToLiveSeconds,
|
||||
enable: timeToLive() === 'on'"
|
||||
/>
|
||||
second(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geospatial - start -->
|
||||
<div data-bind="visible: geospatialVisible">
|
||||
<div class="formTitle">Geospatial Configuration</div>
|
||||
|
||||
<div class="tabs disableFocusDefaults" aria-label="Geospatial Configuration" role="radiogroup">
|
||||
<div class="tab">
|
||||
<label
|
||||
for="geography"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
'aria-checked': geospatialConfigType().toLowerCase() !== GEOMETRY.toLowerCase() ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: geospatialConfigType.editableIsDirty,
|
||||
selectedRadio: geospatialConfigType().toLowerCase() !== GEOMETRY.toLowerCase(),
|
||||
unselectedRadio: geospatialConfigType().toLowerCase() === GEOMETRY.toLowerCase()
|
||||
},
|
||||
event: {
|
||||
keypress: onGeographyKeyPress
|
||||
}"
|
||||
>Geography</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="geospatial"
|
||||
id="geography"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr: {
|
||||
value: GEOGRAPHY
|
||||
},
|
||||
checked: geospatialConfigType"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tab">
|
||||
<label
|
||||
for="geometry"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
'aria-checked': geospatialConfigType().toLowerCase() === GEOMETRY.toLowerCase() ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: geospatialConfigType.editableIsDirty,
|
||||
selectedRadio: geospatialConfigType().toLowerCase() === GEOMETRY.toLowerCase(),
|
||||
unselectedRadio: geospatialConfigType().toLowerCase() !== GEOMETRY.toLowerCase()
|
||||
},
|
||||
event: {
|
||||
keypress: onGeometryKeyPress
|
||||
}"
|
||||
>Geometry</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="geospatial"
|
||||
id="geometry"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr: {
|
||||
value: GEOMETRY
|
||||
},
|
||||
checked: geospatialConfigType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Geospatial - end -->
|
||||
|
||||
<div data-bind="visible: isAnalyticalStorageEnabled">
|
||||
<div class="formTitle">Analytical Storage Time to Live</div>
|
||||
<div class="tabs disableFocusDefaults" aria-label="Analytical Storage Time to Live" role="radiogroup">
|
||||
<div class="tab">
|
||||
<label tabindex="0" role="radio" class="disabledRadio">Off </label>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<label
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: 'analyticalStorageTtlOnNoDefaultId',
|
||||
'aria-checked': analyticalStorageTtlSelection() === 'on-nodefault' ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: analyticalStorageTtlSelection.editableIsDirty,
|
||||
selectedRadio: analyticalStorageTtlSelection() === 'on-nodefault',
|
||||
unselectedRadio: analyticalStorageTtlSelection() !== 'on-nodefault'
|
||||
},
|
||||
event: {
|
||||
keypress: onAnalyticalStorageTtlOnNoDefaultKeyPress
|
||||
}"
|
||||
>On (no default)
|
||||
</label>
|
||||
<input
|
||||
type="radio"
|
||||
name="analyticalStorageTtl"
|
||||
value="on-nodefault"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: 'analyticalStorageTtlOnNoDefaultId'
|
||||
},
|
||||
checked: analyticalStorageTtlSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tab">
|
||||
<label
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
for="ttl3"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: 'analyticalStorageTtlOnId',
|
||||
'aria-checked': analyticalStorageTtlSelection() === 'on' ? 'true' : 'false'
|
||||
},
|
||||
css: {
|
||||
dirty: analyticalStorageTtlSelection.editableIsDirty,
|
||||
selectedRadio: analyticalStorageTtlSelection() === 'on',
|
||||
unselectedRadio: analyticalStorageTtlSelection() !== 'on'
|
||||
},
|
||||
event: {
|
||||
keypress: onAnalyticalStorageTtlOnKeyPress
|
||||
}"
|
||||
>On</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="analyticalStorageTtl"
|
||||
value="on"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: 'analyticalStorageTtlOnId'
|
||||
},
|
||||
checked: analyticalStorageTtlSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div data-bind="visible: analyticalStorageTtlSelection() === 'on'">
|
||||
<input
|
||||
class="dirtyTextbox"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="2147483647"
|
||||
aria-label="Time to live in seconds"
|
||||
data-bind="
|
||||
css: {
|
||||
dirty: analyticalStorageTtlSelection.editableIsDirty
|
||||
},
|
||||
textInput: analyticalStorageTtlSeconds,
|
||||
enable: analyticalStorageTtlSelection() === 'on'"
|
||||
/>
|
||||
second(s)
|
||||
</div>
|
||||
</div>
|
||||
<div data-bind="visible: changeFeedPolicyVisible">
|
||||
<div class="formTitle">
|
||||
<span>Change feed log retention policy</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext infoTooltipWidth"
|
||||
>Enable change feed log retention policy to retain last 10 minutes of history for items in the container
|
||||
by default. To support this, the request unit (RU) charge for this container will be multiplied by a
|
||||
factor of two for writes. Reads are unaffected.</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tabs disableFocusDefaults" aria-label="Change feed selection tabs">
|
||||
<div class="tab">
|
||||
<label
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: changeFeedPolicyOffId
|
||||
},
|
||||
css: {
|
||||
dirty: changeFeedPolicyToggled.editableIsDirty,
|
||||
selectedRadio: changeFeedPolicyToggled() === 'Off',
|
||||
unselectedRadio: changeFeedPolicyToggled() === 'On'
|
||||
},
|
||||
event: {
|
||||
keypress: onChangeFeedPolicyOffKeyPress
|
||||
}"
|
||||
>Off</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="changeFeedPolicy"
|
||||
value="Off"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: changeFeedPolicyOffId
|
||||
},
|
||||
checked: changeFeedPolicyToggled"
|
||||
/>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<label
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
attr:{
|
||||
for: changeFeedPolicyOnId
|
||||
},
|
||||
css: {
|
||||
dirty: changeFeedPolicyToggled.editableIsDirty,
|
||||
selectedRadio: changeFeedPolicyToggled() === 'On',
|
||||
unselectedRadio: changeFeedPolicyToggled() === 'Off'
|
||||
},
|
||||
event: {
|
||||
keypress: onChangeFeedPolicyOnKeyPress
|
||||
}"
|
||||
>On</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="changeFeedPolicy"
|
||||
value="On"
|
||||
class="radio"
|
||||
data-bind="
|
||||
attr:{
|
||||
id: changeFeedPolicyOnId
|
||||
},
|
||||
checked: changeFeedPolicyToggled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-bind="visible: partitionKeyVisible">
|
||||
<div class="formTitle" data-bind="text: partitionKeyName">Partition Key</div>
|
||||
<input
|
||||
class="formReadOnly collid-white"
|
||||
data-bind="textInput: partitionKeyValue, attr: { 'aria-label':partitionKeyName }"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="largePartitionKeyEnabled" data-bind="visible: isLargePartitionKeyEnabled">
|
||||
<p data-bind="visible: isLargePartitionKeyEnabled">
|
||||
Large <span data-bind="text:lowerCasePartitionKeyName"></span> has been enabled
|
||||
</p>
|
||||
</div>
|
||||
<div data-bind="visible: shouldShowIndexingPolicyEditor">
|
||||
<div class="formTitle">Indexing Policy</div>
|
||||
<div
|
||||
class="indexingPolicyEditor ttlIndexingPolicyFocusElement"
|
||||
tabindex="0"
|
||||
data-bind="setTemplateReady: true, attr:{ id: indexingPolicyEditorId }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
656
src/Explorer/Tabs/SettingsTab.test.ts
Normal file
656
src/Explorer/Tabs/SettingsTab.test.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import Collection from "../Tree/Collection";
|
||||
import Database from "../Tree/Database";
|
||||
import DocumentClientUtilityBase from "../../Common/DocumentClientUtilityBase";
|
||||
import Explorer from "../Explorer";
|
||||
import SettingsTab from "../Tabs/SettingsTab";
|
||||
import { DataAccessUtility } from "../../Platform/Portal/DataAccessUtility";
|
||||
|
||||
jest.mock("./NotebookTab");
|
||||
|
||||
describe("Settings tab", () => {
|
||||
const baseCollection: DataModels.Collection = {
|
||||
defaultTtl: 200,
|
||||
partitionKey: null,
|
||||
conflictResolutionPolicy: {
|
||||
mode: DataModels.ConflictResolutionMode.LastWriterWins,
|
||||
conflictResolutionPath: "/_ts"
|
||||
},
|
||||
indexingPolicy: {},
|
||||
_rid: "",
|
||||
_self: "",
|
||||
_etag: "",
|
||||
_ts: 0,
|
||||
id: "mycoll"
|
||||
};
|
||||
|
||||
const baseDatabase: DataModels.Database = {
|
||||
_rid: "",
|
||||
_self: "",
|
||||
_etag: "",
|
||||
_ts: 0,
|
||||
id: "mydb",
|
||||
collections: [baseCollection]
|
||||
};
|
||||
|
||||
const quotaInfo: DataModels.CollectionQuotaInfo = {
|
||||
storedProcedures: 0,
|
||||
triggers: 0,
|
||||
functions: 0,
|
||||
documentsSize: 0,
|
||||
documentsCount: 0,
|
||||
collectionSize: 0,
|
||||
usageSizeInKB: 0,
|
||||
numPartitions: 0
|
||||
};
|
||||
|
||||
describe("Conflict Resolution", () => {
|
||||
describe("should show conflict resolution", () => {
|
||||
let explorer: Explorer;
|
||||
const baseCollectionWithoutConflict: DataModels.Collection = {
|
||||
defaultTtl: 200,
|
||||
partitionKey: null,
|
||||
conflictResolutionPolicy: null,
|
||||
indexingPolicy: {},
|
||||
_rid: "",
|
||||
_self: "",
|
||||
_etag: "",
|
||||
_ts: 0,
|
||||
id: "mycoll"
|
||||
};
|
||||
const getSettingsTab = (conflictResolution: boolean = true) => {
|
||||
return new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: undefined,
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: new Collection(
|
||||
explorer,
|
||||
"mydb",
|
||||
conflictResolution ? baseCollection : baseCollectionWithoutConflict,
|
||||
quotaInfo,
|
||||
null
|
||||
),
|
||||
onUpdateTabsButtons: undefined,
|
||||
openedTabs: []
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
explorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
|
||||
});
|
||||
|
||||
it("single master, should not show conflict resolution", () => {
|
||||
const settingsTab = getSettingsTab();
|
||||
expect(settingsTab.hasConflictResolution()).toBe(false);
|
||||
});
|
||||
|
||||
it("multi master with resolution conflict, show conflict resolution", () => {
|
||||
explorer.databaseAccount({
|
||||
id: "test",
|
||||
kind: "",
|
||||
location: "",
|
||||
name: "",
|
||||
tags: "",
|
||||
type: "",
|
||||
properties: {
|
||||
enableMultipleWriteLocations: true,
|
||||
documentEndpoint: "",
|
||||
cassandraEndpoint: "",
|
||||
gremlinEndpoint: "",
|
||||
tableEndpoint: ""
|
||||
}
|
||||
});
|
||||
|
||||
const settingsTab = getSettingsTab();
|
||||
expect(settingsTab.hasConflictResolution()).toBe(true);
|
||||
});
|
||||
|
||||
it("multi master without resolution conflict, show conflict resolution", () => {
|
||||
explorer.databaseAccount({
|
||||
id: "test",
|
||||
kind: "",
|
||||
location: "",
|
||||
name: "",
|
||||
tags: "",
|
||||
type: "",
|
||||
properties: {
|
||||
enableMultipleWriteLocations: true,
|
||||
documentEndpoint: "",
|
||||
cassandraEndpoint: "",
|
||||
gremlinEndpoint: "",
|
||||
tableEndpoint: ""
|
||||
}
|
||||
});
|
||||
|
||||
const settingsTab = getSettingsTab(false /* no resolution conflict*/);
|
||||
expect(settingsTab.hasConflictResolution()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parse Conflict Resolution Mode from backend", () => {
|
||||
it("should parse any casing", () => {
|
||||
expect(SettingsTab.parseConflictResolutionMode("custom")).toBe(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(SettingsTab.parseConflictResolutionMode("Custom")).toBe(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(SettingsTab.parseConflictResolutionMode("lastWriterWins")).toBe(
|
||||
DataModels.ConflictResolutionMode.LastWriterWins
|
||||
);
|
||||
expect(SettingsTab.parseConflictResolutionMode("LastWriterWins")).toBe(
|
||||
DataModels.ConflictResolutionMode.LastWriterWins
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse empty as null", () => {
|
||||
expect(SettingsTab.parseConflictResolutionMode("")).toBe(null);
|
||||
});
|
||||
|
||||
it("should parse null as null", () => {
|
||||
expect(SettingsTab.parseConflictResolutionMode(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parse Conflict Resolution procedure from backend", () => {
|
||||
it("should parse path as name", () => {
|
||||
expect(SettingsTab.parseConflictResolutionProcedure("/dbs/xxxx/colls/xxxx/sprocs/validsproc")).toBe(
|
||||
"validsproc"
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse name as name", () => {
|
||||
expect(SettingsTab.parseConflictResolutionProcedure("validsproc")).toBe("validsproc");
|
||||
});
|
||||
|
||||
it("should parse invalid path as null", () => {
|
||||
expect(SettingsTab.parseConflictResolutionProcedure("/not/a/valid/path")).toBe(null);
|
||||
});
|
||||
|
||||
it("should parse empty or null as null", () => {
|
||||
expect(SettingsTab.parseConflictResolutionProcedure("")).toBe(null);
|
||||
expect(SettingsTab.parseConflictResolutionProcedure(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Should update collection", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
explorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
|
||||
});
|
||||
|
||||
it("On TTL changed", () => {
|
||||
const settingsTab = new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(false);
|
||||
settingsTab.timeToLive("off");
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(true);
|
||||
|
||||
settingsTab.onRevertClick();
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(false);
|
||||
settingsTab.timeToLiveSeconds(100);
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(true);
|
||||
});
|
||||
|
||||
it("On Index Policy changed", () => {
|
||||
const settingsTab = new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(false);
|
||||
settingsTab.indexingPolicyContent({ somethingDifferent: "" });
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(true);
|
||||
});
|
||||
|
||||
it("On Conflict Resolution Mode changed", () => {
|
||||
const settingsTab = new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(false);
|
||||
settingsTab.conflictResolutionPolicyMode(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(true);
|
||||
|
||||
settingsTab.onRevertClick();
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(false);
|
||||
settingsTab.conflictResolutionPolicyPath("/somethingElse");
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(true);
|
||||
|
||||
settingsTab.onRevertClick();
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(false);
|
||||
settingsTab.conflictResolutionPolicyMode(DataModels.ConflictResolutionMode.Custom);
|
||||
settingsTab.conflictResolutionPolicyProcedure("resolver");
|
||||
expect(settingsTab.shouldUpdateCollection()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Get Conflict Resolution configuration from user", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
explorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
|
||||
});
|
||||
|
||||
it("null if it didnt change", () => {
|
||||
const settingsTab = new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(settingsTab.getUpdatedConflictResolutionPolicy()).toBe(null);
|
||||
});
|
||||
|
||||
it("Custom contains valid backend path", () => {
|
||||
const settingsTab = new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(settingsTab.getUpdatedConflictResolutionPolicy()).toBe(null);
|
||||
settingsTab.conflictResolutionPolicyMode(DataModels.ConflictResolutionMode.Custom);
|
||||
settingsTab.conflictResolutionPolicyProcedure("resolver");
|
||||
let updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
|
||||
expect(updatedPolicy.mode).toBe(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(updatedPolicy.conflictResolutionProcedure).toBe("/dbs/mydb/colls/mycoll/sprocs/resolver");
|
||||
|
||||
settingsTab.conflictResolutionPolicyProcedure("");
|
||||
updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
|
||||
expect(updatedPolicy.conflictResolutionProcedure).toBe(undefined);
|
||||
});
|
||||
|
||||
it("LWW contains valid property path", () => {
|
||||
const settingsTab = new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: new Collection(explorer, "mydb", baseCollection, quotaInfo, null),
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
|
||||
expect(settingsTab.getUpdatedConflictResolutionPolicy()).toBe(null);
|
||||
settingsTab.conflictResolutionPolicyPath("someAttr");
|
||||
let updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
|
||||
expect(updatedPolicy.conflictResolutionPath).toBe("/someAttr");
|
||||
|
||||
settingsTab.conflictResolutionPolicyPath("/someAttr");
|
||||
updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
|
||||
expect(updatedPolicy.conflictResolutionPath).toBe("/someAttr");
|
||||
|
||||
settingsTab.conflictResolutionPolicyPath("");
|
||||
updatedPolicy = settingsTab.getUpdatedConflictResolutionPolicy();
|
||||
expect(updatedPolicy.conflictResolutionPath).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("partitionKeyVisible", () => {
|
||||
enum PartitionKeyOption {
|
||||
None,
|
||||
System,
|
||||
NonSystem
|
||||
}
|
||||
|
||||
function getCollection(defaultApi: string, partitionKeyOption: PartitionKeyOption) {
|
||||
const explorer = new Explorer({
|
||||
documentClientUtility: null,
|
||||
notificationsClient: null,
|
||||
isEmulator: false
|
||||
});
|
||||
explorer.defaultExperience(defaultApi);
|
||||
explorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
|
||||
|
||||
const offer: DataModels.Offer = null;
|
||||
const defaultTtl = 200;
|
||||
const indexingPolicy = {};
|
||||
const database = new Database(explorer, baseDatabase, null);
|
||||
const conflictResolutionPolicy = {
|
||||
mode: DataModels.ConflictResolutionMode.LastWriterWins,
|
||||
conflictResolutionPath: "/_ts"
|
||||
};
|
||||
|
||||
return new Collection(
|
||||
explorer,
|
||||
"mydb",
|
||||
{
|
||||
defaultTtl: defaultTtl,
|
||||
partitionKey:
|
||||
partitionKeyOption != PartitionKeyOption.None
|
||||
? {
|
||||
paths: ["/foo"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
systemKey: partitionKeyOption === PartitionKeyOption.System
|
||||
}
|
||||
: null,
|
||||
conflictResolutionPolicy: conflictResolutionPolicy,
|
||||
indexingPolicy: indexingPolicy,
|
||||
_rid: "",
|
||||
_self: "",
|
||||
_etag: "",
|
||||
_ts: 0,
|
||||
id: "mycoll"
|
||||
},
|
||||
quotaInfo,
|
||||
offer
|
||||
);
|
||||
}
|
||||
|
||||
function getSettingsTab(defaultApi: string, partitionKeyOption: PartitionKeyOption): SettingsTab {
|
||||
return new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: getCollection(defaultApi, partitionKeyOption),
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
}
|
||||
|
||||
it("on SQL container with no partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.DocumentDB, PartitionKeyOption.None);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on Mongo container with no partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.MongoDB, PartitionKeyOption.None);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on Gremlin container with no partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Graph, PartitionKeyOption.None);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on Cassandra container with no partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Cassandra, PartitionKeyOption.None);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on Table container with no partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Table, PartitionKeyOption.None);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on SQL container with system partition key should be true", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.DocumentDB, PartitionKeyOption.System);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("on Mongo container with system partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.MongoDB, PartitionKeyOption.System);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on Gremlin container with system partition key should be true", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Graph, PartitionKeyOption.System);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("on Cassandra container with system partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Cassandra, PartitionKeyOption.System);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on Table container with system partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Table, PartitionKeyOption.System);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on SQL container with non-system partition key should be true", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.DocumentDB, PartitionKeyOption.NonSystem);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("on Mongo container with non-system partition key should be true", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.MongoDB, PartitionKeyOption.NonSystem);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("on Gremlin container with non-system partition key should be true", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Graph, PartitionKeyOption.NonSystem);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("on Cassandra container with non-system partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Cassandra, PartitionKeyOption.NonSystem);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it("on Table container with non-system partition key should be false", () => {
|
||||
const settingsTab = getSettingsTab(Constants.DefaultAccountExperience.Table, PartitionKeyOption.NonSystem);
|
||||
expect(settingsTab.partitionKeyVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutoPilot", () => {
|
||||
function getCollection(autoPilotTier: DataModels.AutopilotTier) {
|
||||
const explorer = new Explorer({
|
||||
documentClientUtility: null,
|
||||
notificationsClient: null,
|
||||
isEmulator: false
|
||||
});
|
||||
explorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
|
||||
|
||||
explorer.databaseAccount({
|
||||
id: "test",
|
||||
kind: "",
|
||||
location: "",
|
||||
name: "",
|
||||
tags: "",
|
||||
type: "",
|
||||
properties: {
|
||||
enableMultipleWriteLocations: true,
|
||||
documentEndpoint: "",
|
||||
cassandraEndpoint: "",
|
||||
gremlinEndpoint: "",
|
||||
tableEndpoint: ""
|
||||
}
|
||||
});
|
||||
|
||||
const offer: DataModels.Offer = {
|
||||
id: "test",
|
||||
_etag: "_etag",
|
||||
_rid: "_rid",
|
||||
_self: "_self",
|
||||
_ts: "_ts",
|
||||
content: {
|
||||
offerThroughput: 0,
|
||||
offerIsRUPerMinuteThroughputEnabled: false,
|
||||
offerAutopilotSettings: {
|
||||
tier: autoPilotTier
|
||||
}
|
||||
}
|
||||
};
|
||||
const database = new Database(explorer, baseDatabase, null);
|
||||
const container: DataModels.Collection = {
|
||||
_rid: "_rid",
|
||||
_self: "",
|
||||
_etag: "",
|
||||
_ts: 0,
|
||||
id: "mycoll",
|
||||
conflictResolutionPolicy: {
|
||||
mode: DataModels.ConflictResolutionMode.LastWriterWins,
|
||||
conflictResolutionPath: "/_ts"
|
||||
}
|
||||
};
|
||||
|
||||
return new Collection(explorer, "mydb", container, quotaInfo, offer);
|
||||
}
|
||||
|
||||
function getSettingsTab(autoPilotTier: DataModels.AutopilotTier = DataModels.AutopilotTier.Tier1): SettingsTab {
|
||||
return new SettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.Settings,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
documentClientUtility: new DocumentClientUtilityBase(new DataAccessUtility()),
|
||||
|
||||
selfLink: "",
|
||||
hashLocation: "",
|
||||
isActive: ko.observable(false),
|
||||
collection: getCollection(autoPilotTier),
|
||||
onUpdateTabsButtons: (buttons: ViewModels.NavbarButtonConfig[]): void => {},
|
||||
openedTabs: []
|
||||
});
|
||||
}
|
||||
describe("Visible", () => {
|
||||
it("no autopilot configured, should not be visible", () => {
|
||||
const settingsTab1 = getSettingsTab(0);
|
||||
expect(settingsTab1.isAutoPilotSelected()).toBe(false);
|
||||
|
||||
const settingsTab2 = getSettingsTab(2);
|
||||
expect(settingsTab2.isAutoPilotSelected()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Autopilot Save", () => {
|
||||
it("edit with valid new tier, save should be enabled", () => {
|
||||
const settingsTab = getSettingsTab(DataModels.AutopilotTier.Tier2);
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
|
||||
settingsTab.selectedAutoPilotTier(DataModels.AutopilotTier.Tier3);
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(true);
|
||||
|
||||
settingsTab.selectedAutoPilotTier(DataModels.AutopilotTier.Tier2);
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("edit with same tier, save should be disabled", () => {
|
||||
const settingsTab = getSettingsTab(DataModels.AutopilotTier.Tier2);
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
|
||||
settingsTab.selectedAutoPilotTier(DataModels.AutopilotTier.Tier2);
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("edit with invalid tier, save should be disabled", () => {
|
||||
const settingsTab = getSettingsTab(DataModels.AutopilotTier.Tier2);
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
|
||||
settingsTab.selectedAutoPilotTier(5);
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Autopilot Discard", () => {
|
||||
it("edit tier, discard should be enabled and correctly dicard", () => {
|
||||
const settingsTab = getSettingsTab(DataModels.AutopilotTier.Tier2);
|
||||
expect(settingsTab.discardSettingsChangesButton.enabled()).toBe(false);
|
||||
|
||||
settingsTab.selectedAutoPilotTier(DataModels.AutopilotTier.Tier3);
|
||||
expect(settingsTab.discardSettingsChangesButton.enabled()).toBe(true);
|
||||
|
||||
settingsTab.onRevertClick();
|
||||
expect(settingsTab.selectedAutoPilotTier()).toBe(DataModels.AutopilotTier.Tier2);
|
||||
|
||||
settingsTab.selectedAutoPilotTier(0);
|
||||
expect(settingsTab.discardSettingsChangesButton.enabled()).toBe(true);
|
||||
|
||||
settingsTab.onRevertClick();
|
||||
expect(settingsTab.selectedAutoPilotTier()).toBe(DataModels.AutopilotTier.Tier2);
|
||||
});
|
||||
});
|
||||
|
||||
it("On TTL changed", () => {
|
||||
const settingsTab = getSettingsTab(DataModels.AutopilotTier.Tier1);
|
||||
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
settingsTab.timeToLive("on");
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(true);
|
||||
expect(settingsTab.discardSettingsChangesButton.enabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("On Index Policy changed", () => {
|
||||
const settingsTab = getSettingsTab(DataModels.AutopilotTier.Tier1);
|
||||
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
settingsTab.indexingPolicyContent({ somethingDifferent: "" });
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(true);
|
||||
expect(settingsTab.discardSettingsChangesButton.enabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("On Conflict Resolution Mode changed", () => {
|
||||
const settingsTab = getSettingsTab(DataModels.AutopilotTier.Tier1);
|
||||
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
settingsTab.conflictResolutionPolicyPath("/somethingElse");
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(true);
|
||||
expect(settingsTab.discardSettingsChangesButton.enabled()).toBe(true);
|
||||
|
||||
settingsTab.onRevertClick();
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(false);
|
||||
settingsTab.conflictResolutionPolicyMode(DataModels.ConflictResolutionMode.Custom);
|
||||
settingsTab.conflictResolutionPolicyProcedure("resolver");
|
||||
expect(settingsTab.saveSettingsButton.enabled()).toBe(true);
|
||||
expect(settingsTab.discardSettingsChangesButton.enabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
1732
src/Explorer/Tabs/SettingsTab.ts
Normal file
1732
src/Explorer/Tabs/SettingsTab.ts
Normal file
File diff suppressed because it is too large
Load Diff
7
src/Explorer/Tabs/SparkMasterTab.html
Normal file
7
src/Explorer/Tabs/SparkMasterTab.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div style="width: 100%; height: 100%; margin-left: 3px;" data-bind="attr: { id: tabId }">
|
||||
<!-- This runs the NotebookApp hosted by DataExplorer -->
|
||||
<iframe
|
||||
style="width:100%; height: 100%; border:none"
|
||||
data-bind="setTemplateReady: true, attr: { src: sparkMasterSrc }, visible: !!sparkMasterSrc()"
|
||||
></iframe>
|
||||
</div>
|
||||
29
src/Explorer/Tabs/SparkMasterTab.ts
Normal file
29
src/Explorer/Tabs/SparkMasterTab.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as ko from "knockout";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
|
||||
export default class SparkMasterTab extends TabsBase {
|
||||
public sparkMasterSrc: ko.Observable<string>;
|
||||
|
||||
private _clusterConnectionInfo: DataModels.SparkClusterConnectionInfo;
|
||||
private _container: ViewModels.Explorer;
|
||||
|
||||
constructor(options: ViewModels.SparkMasterTabOptions) {
|
||||
super(options);
|
||||
super.onActivate.bind(this);
|
||||
this._container = options.container;
|
||||
this._clusterConnectionInfo = options.clusterConnectionInfo;
|
||||
const sparkMasterEndpoint =
|
||||
this._clusterConnectionInfo &&
|
||||
this._clusterConnectionInfo.endpoints &&
|
||||
this._clusterConnectionInfo.endpoints.find(
|
||||
endpoint => endpoint.kind === DataModels.SparkClusterEndpointKind.SparkUI
|
||||
);
|
||||
this.sparkMasterSrc = ko.observable<string>(sparkMasterEndpoint && sparkMasterEndpoint.endpoint);
|
||||
}
|
||||
|
||||
protected getContainer() {
|
||||
return this._container;
|
||||
}
|
||||
}
|
||||
89
src/Explorer/Tabs/StoredProcedureTab.html
Normal file
89
src/Explorer/Tabs/StoredProcedureTab.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<div class="tab-pane flexContainer" data-bind="attr:{ id: tabId }" role="tabpanel">
|
||||
<!-- Stored Procedure Tab Form - Start -->
|
||||
<div class="storedTabForm flexContainer">
|
||||
<div class="formTitleFirst">Stored Procedure Id</div>
|
||||
<span class="formTitleTextbox">
|
||||
<input
|
||||
class="formTree"
|
||||
type="text"
|
||||
required
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
aria-label="Stored procedure id"
|
||||
placeholder="Enter the new stored procedure id"
|
||||
size="40"
|
||||
data-bind="
|
||||
textInput: id"
|
||||
/>
|
||||
</span>
|
||||
<div class="spUdfTriggerHeader">Stored Procedure Body</div>
|
||||
<editor
|
||||
params="{
|
||||
content: originalSprocBody,
|
||||
contentType: 'javascript',
|
||||
isReadOnly: false,
|
||||
ariaLabel: 'Stored procedure body',
|
||||
lineNumbers: 'on',
|
||||
updatedContent: editorContent,
|
||||
theme: _theme
|
||||
}"
|
||||
data-bind="attr: { id: editorId }"
|
||||
></editor>
|
||||
<!-- Results & Errors Content - Start-->
|
||||
<div class="results-container" data-bind="visible: hasResults">
|
||||
<div
|
||||
class="toggles"
|
||||
id="execute-storedproc-toggles"
|
||||
aria-label="Successful execution of stored procedure"
|
||||
data-bind="event: { keydown: onToggleKeyDown }"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="tab">
|
||||
<input type="radio" class="radio" value="result" />
|
||||
<span
|
||||
class="toggleSwitch"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-bind="click: toggleResult, css:{ selectedToggle: isResultToggled(), unselectedToggle: !isResultToggled() }"
|
||||
aria-label="Result"
|
||||
>Result</span
|
||||
>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<input type="radio" class="radio" value="logs" />
|
||||
<span
|
||||
class="toggleSwitch"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-bind="click: toggleLogs, css:{ selectedToggle: isLogsToggled(), unselectedToggle: !isLogsToggled() }"
|
||||
aria-label="console.log"
|
||||
>console.log</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<json-editor
|
||||
params="{ content: resultsData, isReadOnly: true, ariaLabel: 'Execute stored procedure result' }"
|
||||
data-bind="attr: { id: executeResultsEditorId }, visible: hasResults() && isResultToggled()"
|
||||
>
|
||||
</json-editor>
|
||||
<json-editor
|
||||
params="{ content: logsData, isReadOnly: true, ariaLabel: 'Execute stored procedure logs' }"
|
||||
data-bind="attr: { id: executeLogsEditorId }, visible: hasResults() && isLogsToggled()"
|
||||
></json-editor>
|
||||
</div>
|
||||
<div class="errors-container" data-bind="visible: hasErrors">
|
||||
<div class="errors-header">Errors:</div>
|
||||
<div class="errorContent">
|
||||
<span class="errorMessage" data-bind="text: error"></span>
|
||||
<span class="errorDetailsLink">
|
||||
<a
|
||||
data-bind="click: $data.onErrorDetailsClick, event: { keypress: $data.onErrorDetailsKeyPress }"
|
||||
aria-label="Error details link"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results & Errors Content - End-->
|
||||
</div>
|
||||
</div>
|
||||
296
src/Explorer/Tabs/StoredProcedureTab.ts
Normal file
296
src/Explorer/Tabs/StoredProcedureTab.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import * as ko from "knockout";
|
||||
import * as _ from "underscore";
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import editable from "../../Common/EditableUtility";
|
||||
import ScriptTabBase from "./ScriptTabBase";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";
|
||||
|
||||
enum ToggleState {
|
||||
Result = "result",
|
||||
Logs = "logs"
|
||||
}
|
||||
|
||||
export default class StoredProcedureTab extends ScriptTabBase implements ViewModels.StoredProcedureTab {
|
||||
public collection: ViewModels.Collection;
|
||||
public node: ViewModels.StoredProcedure;
|
||||
public executeResultsEditorId: string;
|
||||
public executeLogsEditorId: string;
|
||||
public toggleState: ko.Observable<ToggleState>;
|
||||
|
||||
public originalSprocBody: ViewModels.Editable<string>;
|
||||
public resultsData: ko.Observable<string>;
|
||||
public logsData: ko.Observable<string>;
|
||||
public error: ko.Observable<string>;
|
||||
public hasResults: ko.Observable<boolean>;
|
||||
public hasErrors: ko.Observable<boolean>;
|
||||
|
||||
constructor(options: ViewModels.ScriptTabOption) {
|
||||
super(options);
|
||||
super.onActivate.bind(this);
|
||||
|
||||
this.executeResultsEditorId = `executestoredprocedureresults${this.tabId}`;
|
||||
this.executeLogsEditorId = `executestoredprocedurelogs${this.tabId}`;
|
||||
this.toggleState = ko.observable<ToggleState>(ToggleState.Result);
|
||||
this.originalSprocBody = editable.observable<string>(this.editorContent());
|
||||
this.resultsData = ko.observable<string>();
|
||||
this.logsData = ko.observable<string>();
|
||||
this.error = ko.observable<string>();
|
||||
this.hasResults = ko.observable<boolean>(false);
|
||||
this.hasErrors = ko.observable<boolean>(false);
|
||||
this.error.subscribe((error: string) => {
|
||||
this.hasErrors(error != null);
|
||||
this.hasResults(error == null);
|
||||
});
|
||||
|
||||
this.ariaLabel("Stored Procedure Body");
|
||||
this.buildCommandBarOptions();
|
||||
}
|
||||
|
||||
public onSaveClick = (): Q.Promise<DataModels.StoredProcedure> => {
|
||||
const resource: DataModels.StoredProcedure = <DataModels.StoredProcedure>{
|
||||
id: this.id(),
|
||||
body: this.editorContent()
|
||||
};
|
||||
|
||||
return this._createStoredProcedure(resource);
|
||||
};
|
||||
|
||||
public onDiscard = (): Q.Promise<any> => {
|
||||
this.setBaselines();
|
||||
const original = this.editorContent.getEditableOriginalValue();
|
||||
this.originalSprocBody(original);
|
||||
this.originalSprocBody.valueHasMutated(); // trigger a re-render of the editor
|
||||
|
||||
return Q();
|
||||
};
|
||||
|
||||
public onUpdateClick = (): Q.Promise<any> => {
|
||||
const data: DataModels.StoredProcedure = this._getResource();
|
||||
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.UpdateStoredProcedure, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
return this.documentClientUtility
|
||||
.updateStoredProcedure(this.collection, data)
|
||||
.then(
|
||||
(updatedResource: DataModels.StoredProcedure) => {
|
||||
this.resource(updatedResource);
|
||||
this.tabTitle(updatedResource.id);
|
||||
this.node.id(updatedResource.id);
|
||||
this.node.body(updatedResource.body);
|
||||
this.setBaselines();
|
||||
|
||||
const editorModel = this.editor() && this.editor().getModel();
|
||||
editorModel && editorModel.setValue(updatedResource.body);
|
||||
this.editorContent.setBaseline(updatedResource.body);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateStoredProcedure,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
},
|
||||
(updateError: any) => {
|
||||
this.isExecutionError(true);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateStoredProcedure,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public onExecuteSprocsResult(result: any, logsData: any): void {
|
||||
const resultData: string = this.renderObjectForEditor(_.omit(result, "scriptLogs").result, null, 4);
|
||||
const scriptLogs: string = (result.scriptLogs && decodeURIComponent(result.scriptLogs)) || "";
|
||||
const logs: string = this.renderObjectForEditor(scriptLogs, null, 4);
|
||||
this.error(null);
|
||||
this.resultsData(resultData);
|
||||
this.logsData(logs);
|
||||
}
|
||||
|
||||
public onExecuteSprocsError(error: string): void {
|
||||
this.isExecutionError(true);
|
||||
console.error(error);
|
||||
this.error(error);
|
||||
}
|
||||
|
||||
public onErrorDetailsClick = (src: any, event: MouseEvent): boolean => {
|
||||
this.collection && this.collection.container.expandConsole();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
public onErrorDetailsKeyPress = (src: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.onErrorDetailsClick(src, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public toggleResult(): void {
|
||||
this.toggleState(ToggleState.Result);
|
||||
this.resultsData.valueHasMutated(); // needed to refresh the json-editor component
|
||||
}
|
||||
|
||||
public toggleLogs(): void {
|
||||
this.toggleState(ToggleState.Logs);
|
||||
this.logsData.valueHasMutated(); // needed to refresh the json-editor component
|
||||
}
|
||||
|
||||
public onToggleKeyDown = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.LeftArrow) {
|
||||
this.toggleResult();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
} else if (event.keyCode === Constants.KeyCodes.RightArrow) {
|
||||
this.toggleLogs();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public isResultToggled(): boolean {
|
||||
return this.toggleState() === ToggleState.Result;
|
||||
}
|
||||
|
||||
public isLogsToggled(): boolean {
|
||||
return this.toggleState() === ToggleState.Logs;
|
||||
}
|
||||
|
||||
protected updateSelectedNode(): void {
|
||||
if (this.collection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const database: ViewModels.Database = this.collection.getDatabase();
|
||||
if (!database.isDatabaseExpanded()) {
|
||||
this.collection.container.selectedNode(database);
|
||||
} else if (!this.collection.isCollectionExpanded() || !this.collection.isStoredProceduresExpanded()) {
|
||||
this.collection.container.selectedNode(this.collection);
|
||||
} else {
|
||||
this.collection.container.selectedNode(this.node);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildCommandBarOptions(): void {
|
||||
ko.computed(() => ko.toJSON([this.isNew, this.formIsDirty])).subscribe(() => this.updateNavbarWithTabsButtons());
|
||||
super.buildCommandBarOptions();
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const label = "Execute";
|
||||
return super.getTabsButtons().concat({
|
||||
iconSrc: ExecuteQueryIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: () => {
|
||||
this.collection && this.collection.container.executeSprocParamsPane.open();
|
||||
},
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: this.isNew() || this.formIsDirty()
|
||||
});
|
||||
}
|
||||
|
||||
private _getResource(): DataModels.StoredProcedure {
|
||||
const resource: DataModels.StoredProcedure = <DataModels.StoredProcedure>{
|
||||
_rid: this.resource()._rid,
|
||||
_self: this.resource()._self,
|
||||
id: this.id(),
|
||||
body: this.editorContent()
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
private _createStoredProcedure(resource: DataModels.StoredProcedure): Q.Promise<DataModels.StoredProcedure> {
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateStoredProcedure, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
|
||||
return this.documentClientUtility
|
||||
.createStoredProcedure(this.collection, resource)
|
||||
.then(
|
||||
createdResource => {
|
||||
this.tabTitle(createdResource.id);
|
||||
this.isNew(false);
|
||||
this.resource(createdResource);
|
||||
this.hashLocation(
|
||||
`${Constants.HashRoutePrefixes.collectionsWithIds(
|
||||
this.collection.databaseId,
|
||||
this.collection.id()
|
||||
)}/sprocs/${createdResource.id}`
|
||||
);
|
||||
this.setBaselines();
|
||||
|
||||
const editorModel = this.editor() && this.editor().getModel();
|
||||
editorModel && editorModel.setValue(createdResource.body);
|
||||
this.editorContent.setBaseline(createdResource.body);
|
||||
this.node = this.collection.createStoredProcedureNode(createdResource);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CreateStoredProcedure,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
this.editorState(ViewModels.ScriptEditorState.exisitingNoEdits);
|
||||
return createdResource;
|
||||
},
|
||||
createError => {
|
||||
this.isExecutionError(true);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateStoredProcedure,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
return Q.reject(createError);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
}
|
||||
|
||||
public onDelete(): Q.Promise<any> {
|
||||
// TODO
|
||||
return Q();
|
||||
}
|
||||
}
|
||||
196
src/Explorer/Tabs/TabComponents.ts
Normal file
196
src/Explorer/Tabs/TabComponents.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import DocumentsTabTemplate from "./DocumentsTab.html";
|
||||
import ConflictsTabTemplate from "./ConflictsTab.html";
|
||||
import GraphTabTemplate from "./GraphTab.html";
|
||||
import NotebookTabTemplate from "./NotebookTab.html";
|
||||
import SparkMasterTabTemplate from "./SparkMasterTab.html";
|
||||
import NotebookV2TabTemplate from "./NotebookV2Tab.html";
|
||||
import TerminalTabTemplate from "./TerminalTab.html";
|
||||
import MongoDocumentsTabTemplate from "./MongoDocumentsTab.html";
|
||||
import MongoQueryTabTemplate from "./MongoQueryTab.html";
|
||||
import MongoShellTabTemplate from "./MongoShellTab.html";
|
||||
import QueryTabTemplate from "./QueryTab.html";
|
||||
import QueryTablesTabTemplate from "./QueryTablesTab.html";
|
||||
import SettingsTabTemplate from "./SettingsTab.html";
|
||||
import DatabaseSettingsTabTemplate from "./DatabaseSettingsTab.html";
|
||||
import StoredProcedureTabTemplate from "./StoredProcedureTab.html";
|
||||
import TriggerTabTemplate from "./TriggerTab.html";
|
||||
import UserDefinedFunctionTabTemplate from "./UserDefinedFunctionTab.html";
|
||||
import GalleryTabTemplate from "./GalleryTab.html";
|
||||
import NotebookViewerTabTemplate from "./NotebookViewerTab.html";
|
||||
|
||||
export class TabComponent {
|
||||
constructor(data: any) {
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export class DocumentsTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: DocumentsTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictsTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: ConflictsTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GraphTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: GraphTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class NotebookTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: NotebookTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SparkMasterTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: SparkMasterTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class NotebookV2Tab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: NotebookV2TabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class TerminalTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: TerminalTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class MongoDocumentsTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: MongoDocumentsTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class MongoQueryTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: MongoQueryTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class MongoShellTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: MongoShellTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: QueryTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryTablesTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: QueryTablesTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: SettingsTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseSettingsTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: DatabaseSettingsTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class StoredProcedureTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: StoredProcedureTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class TriggerTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: TriggerTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UserDefinedFunctionTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: UserDefinedFunctionTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GalleryTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: GalleryTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class NotebookViewerTab {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: TabComponent,
|
||||
template: NotebookViewerTabTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
239
src/Explorer/Tabs/TabsBase.ts
Normal file
239
src/Explorer/Tabs/TabsBase.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { RouteHandler } from "../../RouteHandlers/RouteHandler";
|
||||
import { WaitsForTemplateViewModel } from "../WaitsForTemplateViewModel";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import ThemeUtility from "../../Common/ThemeUtility";
|
||||
import DocumentClientUtilityBase from "../../Common/DocumentClientUtilityBase";
|
||||
|
||||
// TODO: Use specific actions for logging telemetry data
|
||||
export default class TabsBase extends WaitsForTemplateViewModel implements ViewModels.Tab {
|
||||
public closeTabButton: ViewModels.Button;
|
||||
public documentClientUtility: DocumentClientUtilityBase;
|
||||
public node: ViewModels.TreeNode;
|
||||
public collection: ViewModels.CollectionBase;
|
||||
public database: ViewModels.Database;
|
||||
public rid: string;
|
||||
public hasFocus: ko.Observable<boolean>;
|
||||
public isActive: ko.Observable<boolean>;
|
||||
public isMouseOver: ko.Observable<boolean>;
|
||||
public tabId: string;
|
||||
public tabKind: ViewModels.CollectionTabKind;
|
||||
public tabTitle: ko.Observable<string>;
|
||||
public tabPath: ko.Observable<string>;
|
||||
public nextTab: ko.Observable<ViewModels.Tab>;
|
||||
public previousTab: ko.Observable<ViewModels.Tab>;
|
||||
public closeButtonTabIndex: ko.Computed<number>;
|
||||
public errorDetailsTabIndex: ko.Computed<number>;
|
||||
public hashLocation: ko.Observable<string>;
|
||||
public isExecutionError: ko.Observable<boolean>;
|
||||
public isExecuting: ko.Observable<boolean>;
|
||||
|
||||
protected _theme: string;
|
||||
public onLoadStartKey: number;
|
||||
|
||||
constructor(options: ViewModels.TabOptions) {
|
||||
super();
|
||||
const id = new Date().getTime().toString();
|
||||
|
||||
this._theme = ThemeUtility.getMonacoTheme(options.theme);
|
||||
this.documentClientUtility = options.documentClientUtility;
|
||||
this.node = options.node;
|
||||
this.collection = options.collection;
|
||||
this.database = options.database;
|
||||
this.rid = options.rid || (this.collection && this.collection.rid) || "";
|
||||
this.hasFocus = ko.observable<boolean>(false);
|
||||
this.isActive = options.isActive || ko.observable<boolean>(false);
|
||||
this.isMouseOver = ko.observable<boolean>(false);
|
||||
this.tabId = `tab${id}`;
|
||||
this.tabKind = options.tabKind;
|
||||
this.tabTitle = ko.observable<string>(options.title);
|
||||
this.tabPath =
|
||||
(options.tabPath && ko.observable<string>(options.tabPath)) ||
|
||||
(this.collection &&
|
||||
ko.observable<string>(`${this.collection.databaseId}>${this.collection.id()}>${this.tabTitle()}`));
|
||||
this.nextTab = ko.observable<ViewModels.Tab>();
|
||||
this.previousTab = ko.observable<ViewModels.Tab>();
|
||||
this.closeButtonTabIndex = ko.computed<number>(() => (this.isActive() ? 0 : null));
|
||||
this.errorDetailsTabIndex = ko.computed<number>(() => (this.isActive() ? 0 : null));
|
||||
this.isExecutionError = ko.observable<boolean>(false);
|
||||
this.isExecuting = ko.observable<boolean>(false);
|
||||
this.onLoadStartKey = options.onLoadStartKey;
|
||||
this.hashLocation = ko.observable<string>(options.hashLocation || "");
|
||||
this.hashLocation.subscribe((newLocation: string) => this.updateGlobalHash(newLocation));
|
||||
|
||||
this.isActive.subscribe((isActive: boolean) => {
|
||||
if (isActive) {
|
||||
this.onActivate();
|
||||
}
|
||||
});
|
||||
|
||||
this.closeTabButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
}),
|
||||
|
||||
visible: ko.computed<boolean>(() => {
|
||||
return true;
|
||||
})
|
||||
};
|
||||
|
||||
const openedTabs = options.openedTabs;
|
||||
if (openedTabs && openedTabs.length && openedTabs.length > 0) {
|
||||
const lastTab = openedTabs[openedTabs.length - 1];
|
||||
lastTab && lastTab.nextTab(this);
|
||||
this.previousTab(lastTab);
|
||||
}
|
||||
}
|
||||
|
||||
public onCloseTabButtonClick(): Q.Promise<any> {
|
||||
const previousTab = this.previousTab();
|
||||
const nextTab = this.nextTab();
|
||||
|
||||
previousTab && previousTab.nextTab(nextTab);
|
||||
nextTab && nextTab.previousTab(previousTab);
|
||||
|
||||
this.getContainer().openedTabs.remove(tab => tab.tabId === this.tabId);
|
||||
|
||||
const tabToActivate = nextTab || previousTab;
|
||||
|
||||
if (!tabToActivate) {
|
||||
this.getContainer().selectedNode(null);
|
||||
this.getContainer().onUpdateTabsButtons([]);
|
||||
this.getContainer().activeTab(null);
|
||||
} else {
|
||||
tabToActivate.isActive(true);
|
||||
this.getContainer().activeTab(tabToActivate);
|
||||
}
|
||||
|
||||
TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, {
|
||||
databaseAccountName: this.getContainer().databaseAccount().name,
|
||||
defaultExperience: this.getContainer().defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onTabClick(): Q.Promise<any> {
|
||||
for (let i = 0; i < this.getContainer().openedTabs().length; i++) {
|
||||
const tab = this.getContainer().openedTabs()[i];
|
||||
tab.isActive(false);
|
||||
}
|
||||
this.isActive(true);
|
||||
this.getContainer().activeTab(this);
|
||||
return Q();
|
||||
}
|
||||
|
||||
protected updateSelectedNode(): void {
|
||||
const relatedDatabase = (this.collection && this.collection.getDatabase()) || this.database;
|
||||
if (relatedDatabase && !relatedDatabase.isDatabaseExpanded()) {
|
||||
this.getContainer().selectedNode(relatedDatabase);
|
||||
} else if (this.collection && !this.collection.isCollectionExpanded()) {
|
||||
this.getContainer().selectedNode(this.collection);
|
||||
} else {
|
||||
this.getContainer().selectedNode(this.node);
|
||||
}
|
||||
}
|
||||
|
||||
private onSpaceOrEnterKeyPress(event: KeyboardEvent, callback: () => void): boolean {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
callback();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public onKeyPressActivate = (source: any, event: KeyboardEvent): boolean => {
|
||||
return this.onSpaceOrEnterKeyPress(event, () => this.onTabClick());
|
||||
};
|
||||
|
||||
public onKeyPressClose = (source: any, event: KeyboardEvent): boolean => {
|
||||
return this.onSpaceOrEnterKeyPress(event, () => this.onCloseTabButtonClick());
|
||||
};
|
||||
|
||||
public onActivate(): Q.Promise<any> {
|
||||
this.updateSelectedNode();
|
||||
if (!!this.collection) {
|
||||
this.collection.selectedSubnodeKind(this.tabKind);
|
||||
}
|
||||
|
||||
if (!!this.database) {
|
||||
this.database.selectedSubnodeKind(this.tabKind);
|
||||
}
|
||||
|
||||
this.hasFocus(true);
|
||||
this.updateGlobalHash(this.hashLocation());
|
||||
|
||||
this.updateNavbarWithTabsButtons();
|
||||
|
||||
TelemetryProcessor.trace(Action.Tab, ActionModifiers.Open, {
|
||||
databaseAccountName: this.getContainer().databaseAccount().name,
|
||||
defaultExperience: this.getContainer().defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
return Q();
|
||||
}
|
||||
|
||||
public onErrorDetailsClick = (src: any, event: MouseEvent): boolean => {
|
||||
if (this.collection && this.collection.container) {
|
||||
this.collection.container.expandConsole();
|
||||
}
|
||||
|
||||
if (this.database && this.database.container) {
|
||||
this.database.container.expandConsole();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
public onErrorDetailsKeyPress = (src: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.onErrorDetailsClick(src, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public refresh(): Q.Promise<any> {
|
||||
location.reload();
|
||||
return Q();
|
||||
}
|
||||
|
||||
protected getContainer(): ViewModels.Explorer {
|
||||
return (this.collection && this.collection.container) || (this.database && this.database.container);
|
||||
}
|
||||
|
||||
/** Renders a Javascript object to be displayed inside Monaco Editor */
|
||||
protected renderObjectForEditor(value: any, replacer: any, space: string | number): string {
|
||||
return JSON.stringify(value, replacer, space);
|
||||
}
|
||||
|
||||
private updateGlobalHash(newHash: string): void {
|
||||
RouteHandler.getInstance().updateRouteHashLocation(newHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return buttons that are displayed in the navbar
|
||||
*/
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected updateNavbarWithTabsButtons = (): void => {
|
||||
if (this.isActive()) {
|
||||
this.getContainer().onUpdateTabsButtons(this.getTabsButtons());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface EditorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
1
src/Explorer/Tabs/TerminalTab.html
Normal file
1
src/Explorer/Tabs/TerminalTab.html
Normal file
@@ -0,0 +1 @@
|
||||
<div style="height: 100%" data-bind="react:notebookTerminalComponentAdapter, setTemplateReady: true"></div>
|
||||
89
src/Explorer/Tabs/TerminalTab.tsx
Normal file
89
src/Explorer/Tabs/TerminalTab.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
import * as React from "react";
|
||||
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
|
||||
import { NotebookTerminalComponent } from "../Controls/Notebook/NotebookTerminalComponent";
|
||||
|
||||
/**
|
||||
* Notebook terminal tab
|
||||
*/
|
||||
class NotebookTerminalComponentAdapter implements ReactAdapter {
|
||||
// parameters: true: show, false: hide
|
||||
public parameters: ko.Computed<boolean>;
|
||||
constructor(
|
||||
private getNotebookServerInfo: () => DataModels.NotebookWorkspaceConnectionInfo,
|
||||
private getDatabaseAccount: () => DataModels.DatabaseAccount
|
||||
) {}
|
||||
|
||||
public renderComponent(): JSX.Element {
|
||||
return this.parameters() ? (
|
||||
<NotebookTerminalComponent
|
||||
notebookServerInfo={this.getNotebookServerInfo()}
|
||||
databaseAccount={this.getDatabaseAccount()}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class TerminalTab extends TabsBase implements ViewModels.Tab {
|
||||
private container: ViewModels.Explorer;
|
||||
private notebookTerminalComponentAdapter: NotebookTerminalComponentAdapter;
|
||||
|
||||
constructor(options: ViewModels.TerminalTabOptions) {
|
||||
super(options);
|
||||
this.container = options.container;
|
||||
this.notebookTerminalComponentAdapter = new NotebookTerminalComponentAdapter(
|
||||
() => this.getNotebookServerInfo(options),
|
||||
() => this.getContainer().databaseAccount()
|
||||
);
|
||||
this.notebookTerminalComponentAdapter.parameters = ko.computed<boolean>(() => {
|
||||
if (this.isTemplateReady() && this.container.isNotebookEnabled()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
protected getContainer(): ViewModels.Explorer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected getTabsButtons(): ViewModels.NavbarButtonConfig[] {
|
||||
const buttons: ViewModels.NavbarButtonConfig[] = [];
|
||||
return buttons;
|
||||
}
|
||||
protected buildCommandBarOptions(): void {
|
||||
this.updateNavbarWithTabsButtons();
|
||||
}
|
||||
|
||||
private getNotebookServerInfo(options: ViewModels.TerminalTabOptions): DataModels.NotebookWorkspaceConnectionInfo {
|
||||
let endpointSuffix: string;
|
||||
|
||||
switch (options.kind) {
|
||||
case ViewModels.TerminalKind.Default:
|
||||
endpointSuffix = "";
|
||||
break;
|
||||
|
||||
case ViewModels.TerminalKind.Mongo:
|
||||
endpointSuffix = "mongo";
|
||||
break;
|
||||
|
||||
case ViewModels.TerminalKind.Cassandra:
|
||||
endpointSuffix = "cassandra";
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Terminal kind: ${options.kind} not supported`);
|
||||
}
|
||||
|
||||
const info: DataModels.NotebookWorkspaceConnectionInfo = options.container.notebookServerInfo();
|
||||
return {
|
||||
authToken: info.authToken,
|
||||
notebookServerEndpoint: `${info.notebookServerEndpoint.replace(/\/+$/, "")}/${endpointSuffix}`
|
||||
};
|
||||
}
|
||||
}
|
||||
39
src/Explorer/Tabs/TriggerTab.html
Normal file
39
src/Explorer/Tabs/TriggerTab.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<div class="tab-pane flexContainer" data-bind="attr:{ id: tabId }" role="tabpanel">
|
||||
<!-- Trigger Tab Form - Start -->
|
||||
<div class="storedTabForm flexContainer">
|
||||
<div class="formTitleFirst">Trigger Id</div>
|
||||
<span class="formTitleTextbox">
|
||||
<input
|
||||
class="formTree"
|
||||
type="text"
|
||||
required
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
placeholder="Enter the new trigger id"
|
||||
size="40"
|
||||
data-bind="textInput: id"
|
||||
aria-label="Trigger Id"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="formTitleFirst">Trigger Type</div>
|
||||
<span class="formTitleTextbox">
|
||||
<select class="formTree" required data-bind="value: triggerType" aria-label="Trigger Type">
|
||||
<option>Pre</option>
|
||||
<option>Post</option>
|
||||
</select>
|
||||
</span>
|
||||
<div class="formTitleFirst">Trigger Operation</div>
|
||||
<span class="formTitleTextbox">
|
||||
<select class="formTree" required data-bind="value: triggerOperation" aria-label="Trigger Operation">
|
||||
<option>All</option>
|
||||
<option>Create</option>
|
||||
<option>Delete</option>
|
||||
<option>Replace</option>
|
||||
</select>
|
||||
</span>
|
||||
<div class="spUdfTriggerHeader">Trigger Body</div>
|
||||
<div class="storedUdfTriggerEditor " data-bind=" setTemplateReady: true, attr: { id: editorId } "></div>
|
||||
</div>
|
||||
<!-- Trigger Tab Form - End -->
|
||||
</div>
|
||||
184
src/Explorer/Tabs/TriggerTab.ts
Normal file
184
src/Explorer/Tabs/TriggerTab.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import ScriptTabBase from "./ScriptTabBase";
|
||||
import editable from "../../Common/EditableUtility";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
export default class TriggerTab extends ScriptTabBase implements ViewModels.TriggerTab {
|
||||
public collection: ViewModels.Collection;
|
||||
public node: ViewModels.Trigger;
|
||||
public triggerType: ViewModels.Editable<string>;
|
||||
public triggerOperation: ViewModels.Editable<string>;
|
||||
|
||||
constructor(options: ViewModels.ScriptTabOption) {
|
||||
super(options);
|
||||
super.onActivate.bind(this);
|
||||
this.ariaLabel("Trigger Body");
|
||||
this.triggerType = editable.observable<string>(options.resource.triggerType);
|
||||
this.triggerOperation = editable.observable<string>(options.resource.triggerOperation);
|
||||
|
||||
this.formFields([this.id, this.triggerType, this.triggerOperation, this.editorContent]);
|
||||
super.buildCommandBarOptions.bind(this);
|
||||
super.buildCommandBarOptions();
|
||||
}
|
||||
|
||||
public onSaveClick = (): Q.Promise<DataModels.Trigger> => {
|
||||
const data: DataModels.Trigger = this._getResource();
|
||||
return this._createTrigger(data);
|
||||
};
|
||||
|
||||
public onUpdateClick = (): Q.Promise<any> => {
|
||||
const data: DataModels.Trigger = this._getResource();
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.UpdateTrigger, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
|
||||
return this.documentClientUtility
|
||||
.updateTrigger(this.collection, data)
|
||||
.then(
|
||||
(createdResource: DataModels.Trigger) => {
|
||||
this.resource(createdResource);
|
||||
this.tabTitle(createdResource.id);
|
||||
|
||||
this.node.id(createdResource.id);
|
||||
this.node.body(createdResource.body);
|
||||
this.node.triggerType(createdResource.triggerOperation);
|
||||
this.node.triggerOperation(createdResource.triggerOperation);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateTrigger,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
this.setBaselines();
|
||||
|
||||
const editorModel = this.editor().getModel();
|
||||
editorModel.setValue(createdResource.body);
|
||||
this.editorContent.setBaseline(createdResource.body);
|
||||
},
|
||||
(createError: any) => {
|
||||
this.isExecutionError(true);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateTrigger,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
public setBaselines() {
|
||||
super.setBaselines();
|
||||
|
||||
const resource = <DataModels.Trigger>this.resource();
|
||||
this.triggerOperation.setBaseline(resource.triggerOperation);
|
||||
this.triggerType.setBaseline(resource.triggerType);
|
||||
}
|
||||
|
||||
protected updateSelectedNode(): void {
|
||||
if (this.collection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const database: ViewModels.Database = this.collection.getDatabase();
|
||||
if (!database.isDatabaseExpanded()) {
|
||||
this.collection.container.selectedNode(database);
|
||||
} else if (!this.collection.isCollectionExpanded() || !this.collection.isTriggersExpanded()) {
|
||||
this.collection.container.selectedNode(this.collection);
|
||||
} else {
|
||||
this.collection.container.selectedNode(this.node);
|
||||
}
|
||||
}
|
||||
|
||||
private _createTrigger(resource: DataModels.Trigger): Q.Promise<DataModels.Trigger> {
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateTrigger, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
|
||||
return this.documentClientUtility
|
||||
.createTrigger(this.collection, resource)
|
||||
.then(
|
||||
(createdResource: DataModels.Trigger) => {
|
||||
this.tabTitle(createdResource.id);
|
||||
this.isNew(false);
|
||||
this.resource(createdResource);
|
||||
this.hashLocation(
|
||||
`${Constants.HashRoutePrefixes.collectionsWithIds(
|
||||
this.collection.databaseId,
|
||||
this.collection.id()
|
||||
)}/triggers/${createdResource.id}`
|
||||
);
|
||||
this.setBaselines();
|
||||
|
||||
const editorModel = this.editor().getModel();
|
||||
editorModel.setValue(createdResource.body);
|
||||
this.editorContent.setBaseline(createdResource.body);
|
||||
|
||||
this.node = this.collection.createTriggerNode(createdResource);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CreateTrigger,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
this.editorState(ViewModels.ScriptEditorState.exisitingNoEdits);
|
||||
return createdResource;
|
||||
},
|
||||
(createError: any) => {
|
||||
this.isExecutionError(true);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateTrigger,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
return Q.reject(createError);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
}
|
||||
|
||||
private _getResource(): DataModels.Trigger {
|
||||
const resource: DataModels.Trigger = <DataModels.Trigger>{
|
||||
_rid: this.resource()._rid,
|
||||
_self: this.resource()._self,
|
||||
id: this.id(),
|
||||
body: this.editorContent(),
|
||||
triggerOperation: this.triggerOperation(),
|
||||
triggerType: this.triggerType()
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
30
src/Explorer/Tabs/UserDefinedFunctionTab.html
Normal file
30
src/Explorer/Tabs/UserDefinedFunctionTab.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<div class="tab-pane flexContainer" data-bind="attr:{ id: tabId }" role="tabpanel">
|
||||
<!-- User Defined Function Tab Form - Start -->
|
||||
<div class="storedTabForm flexContainer">
|
||||
<div class="formTitleFirst">User Defined Function Id</div>
|
||||
<span class="formTitleTextbox">
|
||||
<input
|
||||
class="formTree"
|
||||
type="text"
|
||||
required
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
aria-label="User defined function id"
|
||||
placeholder="Enter the new user defined function id"
|
||||
size="40"
|
||||
data-bind="
|
||||
textInput: id"
|
||||
/>
|
||||
</span>
|
||||
<div class="spUdfTriggerHeader">User Defined Function Body</div>
|
||||
<div
|
||||
class="storedUdfTriggerEditor"
|
||||
data-bind="
|
||||
setTemplateReady: true,
|
||||
attr: {
|
||||
id: editorId
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<!-- User Defined Function Tab Form - End -->
|
||||
</div>
|
||||
166
src/Explorer/Tabs/UserDefinedFunctionTab.ts
Normal file
166
src/Explorer/Tabs/UserDefinedFunctionTab.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import ScriptTabBase from "./ScriptTabBase";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
export default class UserDefinedFunctionTab extends ScriptTabBase implements ViewModels.UserDefinedFunctionTab {
|
||||
public collection: ViewModels.Collection;
|
||||
public node: ViewModels.UserDefinedFunction;
|
||||
constructor(options: ViewModels.ScriptTabOption) {
|
||||
super(options);
|
||||
this.ariaLabel("User Defined Function Body");
|
||||
super.onActivate.bind(this);
|
||||
super.buildCommandBarOptions.bind(this);
|
||||
super.buildCommandBarOptions();
|
||||
}
|
||||
|
||||
public onSaveClick = (): Q.Promise<DataModels.UserDefinedFunction> => {
|
||||
const data: DataModels.UserDefinedFunction = this._getResource();
|
||||
return this._createUserDefinedFunction(data);
|
||||
};
|
||||
|
||||
public onUpdateClick = (): Q.Promise<any> => {
|
||||
const data: DataModels.UserDefinedFunction = this._getResource();
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.UpdateUDF, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
|
||||
return this.documentClientUtility
|
||||
.updateUserDefinedFunction(this.collection, data)
|
||||
.then(
|
||||
(createdResource: DataModels.UserDefinedFunction) => {
|
||||
this.resource(createdResource);
|
||||
this.tabTitle(createdResource.id);
|
||||
|
||||
this.node.id(createdResource.id);
|
||||
this.node.body(createdResource.body);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateUDF,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
this.setBaselines();
|
||||
|
||||
const editorModel = this.editor().getModel();
|
||||
editorModel.setValue(createdResource.body);
|
||||
this.editorContent.setBaseline(createdResource.body);
|
||||
},
|
||||
(createError: any) => {
|
||||
this.isExecutionError(true);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateUDF,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
};
|
||||
|
||||
protected updateSelectedNode(): void {
|
||||
if (this.collection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const database: ViewModels.Database = this.collection.getDatabase();
|
||||
if (!database.isDatabaseExpanded()) {
|
||||
this.collection.container.selectedNode(database);
|
||||
} else if (!this.collection.isCollectionExpanded() || !this.collection.isUserDefinedFunctionsExpanded()) {
|
||||
this.collection.container.selectedNode(this.collection);
|
||||
} else {
|
||||
this.collection.container.selectedNode(this.node);
|
||||
}
|
||||
}
|
||||
|
||||
private _createUserDefinedFunction(
|
||||
resource: DataModels.UserDefinedFunction
|
||||
): Q.Promise<DataModels.UserDefinedFunction> {
|
||||
this.isExecutionError(false);
|
||||
this.isExecuting(true);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateUDF, {
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
});
|
||||
|
||||
return this.documentClientUtility
|
||||
.createUserDefinedFunction(this.collection, resource)
|
||||
.then(
|
||||
(createdResource: DataModels.UserDefinedFunction) => {
|
||||
this.tabTitle(createdResource.id);
|
||||
this.isNew(false);
|
||||
this.resource(createdResource);
|
||||
this.hashLocation(
|
||||
`${Constants.HashRoutePrefixes.collectionsWithIds(this.collection.databaseId, this.collection.id())}/udfs/${
|
||||
createdResource.id
|
||||
}`
|
||||
);
|
||||
this.setBaselines();
|
||||
|
||||
const editorModel = this.editor().getModel();
|
||||
editorModel.setValue(createdResource.body);
|
||||
this.editorContent.setBaseline(createdResource.body);
|
||||
|
||||
this.node = this.collection.createUserDefinedFunctionNode(createdResource);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CreateUDF,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
this.editorState(ViewModels.ScriptEditorState.exisitingNoEdits);
|
||||
return createdResource;
|
||||
},
|
||||
(createError: any) => {
|
||||
this.isExecutionError(true);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateUDF,
|
||||
{
|
||||
databaseAccountName: this.collection && this.collection.container.databaseAccount().name,
|
||||
defaultExperience: this.collection && this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
return Q.reject(createError);
|
||||
}
|
||||
)
|
||||
.finally(() => this.isExecuting(false));
|
||||
}
|
||||
|
||||
private _getResource() {
|
||||
const resource: DataModels.UserDefinedFunction = <DataModels.UserDefinedFunction>{
|
||||
_rid: this.resource()._rid,
|
||||
_self: this.resource()._self,
|
||||
id: this.id(),
|
||||
body: this.editorContent()
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
8
src/Explorer/Tabs/__mocks__/NotebookTab.ts
Normal file
8
src/Explorer/Tabs/__mocks__/NotebookTab.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import TabsBase from "../TabsBase";
|
||||
|
||||
export default class NotebookTab extends TabsBase implements ViewModels.Tab {
|
||||
constructor(options: ViewModels.NotebookTabOptions) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user