Initial Move from Azure DevOps to GitHub

This commit is contained in:
Steve Faulkner
2020-05-25 21:30:55 -05:00
commit 36581fb6d9
986 changed files with 195242 additions and 0 deletions

View 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>

View 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
);
}
}

View 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>

View 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();
}
}

View 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>

View 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);
});
});
});

View 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()
};
}
}

View File

@@ -0,0 +1 @@
<div style="height: 100%" data-bind="react:galleryComponentAdapter, setTemplateReady: true"></div>

View 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();
}
}

View File

@@ -0,0 +1 @@
<div class="graphExplorerContainer" role="tabpanel" data-bind="react:graphExplorerAdapter, attr:{ id: tabId }"></div>

View 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();
}
}

View 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>

View 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));
}
}

View 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>

View 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);
}
}

View 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>

View 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";
}

View 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>

View 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);
}
}

View File

@@ -0,0 +1 @@
<div data-bind="react:notebookComponentAdapter" style="height: 100%"></div>

View 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
});
}
}

View File

@@ -0,0 +1 @@
<div style="height: 100%" data-bind="react:notebookViewerComponentAdapter, setTemplateReady: true"></div>

View 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();
}
}

View 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>

View 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;
}
}
}
}
}

View 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);
});
});
});

View 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();
}
}

View 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>

View 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();
}
}

View 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
};
}
}

View 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>

View 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);
});
});
});

File diff suppressed because it is too large Load Diff

View 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>

View 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;
}
}

View 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>

View 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();
}
}

View 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
};
}
}

View 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;
}

View File

@@ -0,0 +1 @@
<div style="height: 100%" data-bind="react:notebookTerminalComponentAdapter, setTemplateReady: true"></div>

View 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}`
};
}
}

View 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>

View 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;
}
}

View 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>

View 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;
}
}

View 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);
}
}