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,119 @@
import * as _ from "underscore";
import * as ko from "knockout";
enum ScrollPosition {
Top,
Bottom
}
export class AccessibleVerticalList {
private items: any[] = [];
private onSelect: (item: any) => void;
public currentItemIndex: ko.Observable<number>;
public currentItem: ko.Computed<any>;
constructor(initialSetOfItems: any[]) {
this.items = initialSetOfItems;
this.currentItemIndex = this.items != null && this.items.length > 0 ? ko.observable(0) : ko.observable(-1);
this.currentItem = ko.computed<any>(() => this.items[this.currentItemIndex()]);
}
public setOnSelect(onSelect: (item: any) => void): void {
this.onSelect = onSelect;
}
public onKeyDown = (source: any, event: KeyboardEvent): boolean => {
const targetContainer: Element = <Element>event.target;
if (this.items == null || this.items.length === 0) {
// no items so this should be a noop
return true;
}
if (event.keyCode === 32 || event.keyCode === 13) {
// on space or enter keydown
this.onSelect && this.onSelect(this.currentItem());
event.stopPropagation();
return false;
}
if (event.keyCode === 38) {
// on UpArrow keydown
event.preventDefault();
this.selectPreviousItem();
const targetElement = targetContainer
.getElementsByClassName("accessibleListElement")
.item(this.currentItemIndex());
this.scrollElementIntoContainerViewIfNeeded(targetElement, targetContainer, ScrollPosition.Top);
return false;
}
if (event.keyCode === 40) {
// on DownArrow keydown
event.preventDefault();
this.selectNextItem();
const targetElement = targetContainer
.getElementsByClassName("accessibleListElement")
.item(this.currentItemIndex());
this.scrollElementIntoContainerViewIfNeeded(targetElement, targetContainer, ScrollPosition.Bottom);
return false;
}
return true;
};
public updateItemList(newItemList: any[]) {
if (newItemList == null || newItemList.length === 0) {
this.currentItemIndex(-1);
this.items = [];
return;
} else if (this.currentItemIndex() < 0) {
this.currentItemIndex(0);
}
this.items = newItemList;
}
public updateCurrentItem(item: any) {
const updatedIndex: number = this.isItemListEmpty() ? -1 : _.indexOf(this.items, item);
this.currentItemIndex(updatedIndex);
}
private isElementVisibleInContainer(element: Element, container: Element): boolean {
const elementTop = element.getBoundingClientRect().top;
const elementBottom = element.getBoundingClientRect().bottom;
const containerTop = container.getBoundingClientRect().top;
const containerBottom = container.getBoundingClientRect().bottom;
return elementTop >= containerTop && elementBottom <= containerBottom;
}
private scrollElementIntoContainerViewIfNeeded(
element: Element,
container: Element,
scrollPosition: ScrollPosition
): void {
if (!this.isElementVisibleInContainer(element, container)) {
if (scrollPosition === ScrollPosition.Top) {
container.scrollTop =
element.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
} else {
container.scrollTop =
element.getBoundingClientRect().bottom - element.getBoundingClientRect().top + container.scrollTop;
}
}
}
private selectPreviousItem(): void {
if (this.currentItemIndex() <= 0 || this.isItemListEmpty()) {
return;
}
this.currentItemIndex(this.currentItemIndex() - 1);
}
private selectNextItem(): void {
if (this.isItemListEmpty() || this.currentItemIndex() === this.items.length - 1) {
return;
}
this.currentItemIndex(this.currentItemIndex() + 1);
}
private isItemListEmpty(): boolean {
return this.items == null || this.items.length === 0;
}
}

View File

@@ -0,0 +1,114 @@
import * as DataModels from "../../Contracts/DataModels";
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import Collection from "./Collection";
jest.mock("monaco-editor");
describe("Collection", () => {
function generateCollection(
container: ViewModels.Explorer,
databaseId: string,
data: DataModels.Collection,
quotaInfo: DataModels.CollectionQuotaInfo,
offer: DataModels.Offer
): Collection {
return new Collection(container, databaseId, data, quotaInfo, offer);
}
function generateMockCollectionsDataModelWithPartitionKey(
partitionKey: DataModels.PartitionKey
): DataModels.Collection {
return {
defaultTtl: 1,
indexingPolicy: {} as DataModels.IndexingPolicy,
partitionKey,
_rid: "",
_self: "",
_etag: "",
_ts: 1,
id: ""
};
}
function generateMockCollectionWithDataModel(data: DataModels.Collection): Collection {
const mockContainer = {} as ViewModels.Explorer;
mockContainer.isPreferredApiMongoDB = ko.computed(() => {
return false;
});
mockContainer.isPreferredApiCassandra = ko.computed(() => {
return false;
});
mockContainer.isDatabaseNodeOrNoneSelected = () => {
return false;
};
mockContainer.isPreferredApiDocumentDB = ko.computed(() => {
return true;
});
mockContainer.isPreferredApiGraph = ko.computed(() => {
return false;
});
mockContainer.deleteCollectionText = ko.computed(() => {
return "delete collection";
});
return generateCollection(mockContainer, "abc", data, {} as DataModels.CollectionQuotaInfo, {} as DataModels.Offer);
}
describe("Partition key path parsing", () => {
let collection: Collection;
it("should strip out multiple forward slashes from partition key paths", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/somePartitionKey/anotherPartitionKey"],
kind: "Hash",
version: 2
});
collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKeyProperty).toBe("somePartitionKey.anotherPartitionKey");
});
it("should strip out forward slashes from single partition key paths", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/somePartitionKey"],
kind: "Hash",
version: 2
});
collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKeyProperty).toBe("somePartitionKey");
});
});
describe("Partition key path header", () => {
let collection: Collection;
it("should preserve forward slashes on partition keys", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/somePartitionKey/anotherPartitionKey"],
kind: "Hash",
version: 2
});
collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKeyPropertyHeader).toBe("/somePartitionKey/anotherPartitionKey");
});
it("should preserve forward slash on a single partition key", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/somePartitionKey"],
kind: "Hash",
version: 2
});
collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKeyPropertyHeader).toBe("/somePartitionKey");
});
it("should be null if there is no partition key", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
version: 2,
paths: [],
kind: "Hash"
});
collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKeyPropertyHeader).toBeNull;
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,425 @@
<div class="pointerCursor">
<div
role="treeitem"
data-test="collectionList"
tabindex="0"
class="collectionMenu treeHovermargin highlight"
data-bind="
click: $data.expandCollapseCollection,
clickBubble: false,
contextmenuBubble: false,
css:{
collectionNodeSelected: isCollectionNodeSelected(),
contextmenushowing: $data.contextMenu.visible
},
event: {
keydown: onKeyDown,
keypress: onKeyPress,
contextmenu: $data.contextMenu.show,
drop: $data.onDrop,
dragover: $data.onDragOver
},
attr:{
'aria-expanded': $data.isCollectionExpanded,
'aria-selected': isCollectionNodeSelected()
}"
>
<span
class="collectionList databaseCollChildTextOverflow"
data-bind="
attr: {
title: $data.id()
}"
>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-right.svg"
alt="Show collection properties"
data-bind="visible: !$data.isCollectionExpanded()"
/>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-down.svg"
alt="Hide collection properties"
data-bind="visible: $data.isCollectionExpanded()"
/>
<img
src="/tree-collection.svg"
data-bind="
attr: {
alt: container.addCollectionText
}"
/>
<!--ko text: $data.id-->
<!--/ko-->
</span>
<span
class="menuEllipsis"
data-test="collectionEllipsisMenu"
name="context menu"
role="button"
data-bind="click: $data.contextMenu.show, clickBubble: false"
>&hellip;</span
>
</div>
<!-- Collection node children - Start -->
<div
class="collectionChildList"
role="group"
data-bind="
visible: $data.isCollectionExpanded,
clickBubble: false"
>
<!-- Documents Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
class="documentsMenu"
data-bind="
visible: $root.isPreferredApiDocumentDB(),
click: $data.onDocumentDBDocumentsClick,
event: {
keydown: onDocumentDBDocumentsKeyDown,
keypress: onDocumentDBDocumentsKeyPress
},
clickBubble: false,
css: {
highlight: true,
collectionNodeSelected: isSubNodeSelected(0)
},
attr:{
'aria-selected': isSubNodeSelected(0)
}"
>
<span class="databaseDocuments">Items</span>
</div>
</div>
<!-- Documents Node - End -->
<!-- Entitites Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
class="documentsMenu"
data-bind="
visible: $root.isPreferredApiTable(),
click: $data.onTableEntitiesClick,
event: {
keydown: onTableEntitiesKeyDown,
keypress: onTableEntitiesKeyPress
},
clickBubble: false,
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 9
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 9
}"
>
<span class="databaseDocuments">Entities</span>
</div>
</div>
<!-- Entitites Node - End -->
<!-- Rows Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
class="documentsMenu"
data-bind="
visible: $root.isPreferredApiCassandra(),
click: $data.onTableEntitiesClick,
event: {
keydown: onTableEntitiesKeyDown,
keypress: onTableEntitiesKeyPress
},
clickBubble: false,
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 9
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 9
}"
>
<span class="databaseDocuments">Rows</span>
</div>
</div>
<!-- Rows Node - End -->
<!-- Graph Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
class="documentsMenu"
data-bind="
visible: $root.isPreferredApiGraph,
click: $data.onGraphDocumentsClick,
event: {
keydown: onGraphDocumentsKeyDown,
keypress: onGraphDocumentsKeyPress
},
clickBubble: false,
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 6
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 6
}"
>
<span class="databaseDocuments">Graph</span>
</div>
</div>
<!-- Graph Node - End -->
<!-- MongoDB Documents Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
class="documentsMenu"
data-bind="
visible: $root.isPreferredApiMongoDB,
click: $data.onMongoDBDocumentsClick,
event: {
keydown: onMongoDBDocumentsKeyDown,
keypress: onMongoDBDocumentsKeyPress
},
clickBubble: false,
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 0
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 0
}"
>
<span class="databaseDocuments">Documents</span>
</div>
</div>
<!-- MongoDB Documents Node - End -->
<!-- Scale & Setings Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
data-bind="
click: $data.onSettingsClick,
event: {
keydown: onSettingsKeyDown,
keypress: onSettingsKeyPress
},
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 1
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 1
}"
>
<span class="databaseDocuments">
<!-- ko if: !$data.database.isDatabaseShared() -->
Scale & Settings
<!-- /ko -->
<!-- ko if: $data.database.isDatabaseShared() -->
Settings
<!-- /ko -->
</span>
</div>
</div>
<!-- Scale & Setings Node - End -->
<!-- Stored Procedures Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
class="storedProcedureMenu highlight"
data-bind="
click: $data.expandCollapseStoredProcedures,
event: {
keydown: onStoredProceduresKeyDown,
keypress: onStoredProceduresKeyPress
},
css: {
collectionNodeSelected: !isStoredProceduresExpanded() && isSubNodeSelected(2)
},
attr:{
'aria-expanded': $data.isStoredProceduresExpanded(),
'aria-selected': !isStoredProceduresExpanded() && isSubNodeSelected(2)
},
visible: showStoredProcedures"
>
<span
class="collectionMenuChildren"
data-bind="
attr: {
title: $data.id()
}"
>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-right.svg"
alt="Show storedprocedures properties"
data-bind="visible: !$data.isStoredProceduresExpanded()"
/>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-down.svg"
alt="Hide storedprocedures properties"
data-bind="visible: $data.isStoredProceduresExpanded()"
/>
Stored Procedures
</span>
</div>
<div
class="storedUdfTriggerMenu"
data-bind=" visible: $data.isStoredProceduresExpanded(), foreach: $data.storedProcedures"
>
<stored-procedure-node params="{data: $data}"></stored-procedure-node>
</div>
</div>
<!-- Stored Procedures Node - End -->
<!-- User Defined Functions Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
class="userDefinedMenu highlight"
data-bind="
click: $data.expandCollapseUserDefinedFunctions,
event: {
keydown: onUserDefinedFunctionsKeyDown,
keypress: onUserDefinedFunctionsKeyPress
},
css: {
collectionNodeSelected: !isUserDefinedFunctionsExpanded() && isSubNodeSelected(3)
},
attr:{
'aria-expanded': $data.isUserDefinedFunctionsExpanded(),
'aria-selected': !isUserDefinedFunctionsExpanded() && isSubNodeSelected(3)
},
visible: showUserDefinedFunctions"
>
<div>
<span
class="collectionMenuChildren"
data-bind="
attr: {
title: $data.id()
}"
>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-right.svg"
alt="Show userdefinedfunctions properties"
data-bind="visible: !$data.isUserDefinedFunctionsExpanded()"
/>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-down.svg"
alt="Hide userdefinedfunctions properties"
data-bind="visible: $data.isUserDefinedFunctionsExpanded()"
/>
User Defined Functions
</span>
</div>
</div>
<div
class="storedUdfTriggerMenu"
data-bind="visible: $data.isUserDefinedFunctionsExpanded(), foreach: $data.userDefinedFunctions"
>
<user-defined-function-node params="{data: $data}"></user-defined-function-node>
</div>
</div>
<!-- User Defined Functions Node - End -->
<!-- Triggers Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
class="triggersMenu highlight"
data-bind="
click: $data.expandCollapseTriggers,
event: {
keydown: onTriggersKeyDown,
keypress: onTriggersKeyPress
},
css: {
collectionNodeSelected: !isTriggersExpanded() && isSubNodeSelected(4)
},
attr:{
'aria-expanded': $data.isTriggersExpanded(),
'aria-selected': !isTriggersExpanded() && isSubNodeSelected(4)
},
visible: showTriggers"
>
<div>
<span
class="collectionMenuChildren"
data-bind="
attr: {
title: $data.id()
}"
>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-right.svg"
alt="Show Triggers properties"
data-bind="visible: !$data.isTriggersExpanded()"
/>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-down.svg"
alt="Hide Triggers properties"
data-bind="visible: $data.isTriggersExpanded()"
/>
Triggers
</span>
</div>
</div>
<div class="storedUdfTriggerMenu" data-bind="visible: $data.isTriggersExpanded(), foreach: $data.triggers">
<trigger-node params="{data: $data}"></trigger-node>
</div>
</div>
<!-- Triggers Node - End -->
<!-- Conflicts Node - Start -->
<div>
<div
role="treeitem"
tabindex="0"
data-bind="
click: $data.onConflictsClick,
event: {
keypress: onConflictsKeyPress
},
css: {
highlight: true,
collectionNodeSelected: isSubNodeSelected(12)
},
attr:{
'aria-selected': isSubNodeSelected(12)
},
visible: showConflicts"
>
<span class=" databaseDocuments"> Conflicts </span>
</div>
</div>
<!-- Conflicts Node - End -->
</div>
<!-- Collection node children - End -->
</div>

View File

@@ -0,0 +1,16 @@
<div data-bind="event: { keydown: onMenuKeyDown }">
<div
class="context-menu-background"
data-bind="
visible: $data.contextMenu.visible,
click: $data.contextMenu.hide"
></div>
<div
class="context-menu"
data-test="collectionContextMenu"
data-bind="attr:{ tabindex: $data.contextMenu.tabIndex, id: $data.contextMenu.elementId }, visible: $data.contextMenu.visible, foreach: $data.contextMenu.options"
>
<command-button class="context-menu-option" params="{buttonProps: $data}"></command-button>
</div>
</div>

View File

@@ -0,0 +1,151 @@
import Q from "q";
import * as ko from "knockout";
import * as Constants from "../../Common/Constants";
import DocumentId from "./DocumentId";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { extractPartitionKey } from "@azure/cosmos";
export default class ConflictId implements ViewModels.ConflictId {
public container: ViewModels.ConflictsTab;
public rid: string;
public self: string;
public ts: string;
public id: ko.Observable<string>;
public partitionKeyProperty: string;
public partitionKey: DataModels.PartitionKey;
public partitionKeyValue: any;
public stringPartitionKeyValue: string;
public resourceId: string;
public resourceType: string;
public operationType: string;
public content: string;
public parsedContent: any;
public isDirty: ko.Observable<boolean>;
constructor(container: ViewModels.ConflictsTab, data: any) {
this.container = container;
this.self = data._self;
this.rid = data._rid;
this.ts = data._ts;
this.resourceId = data.resourceId;
this.resourceType = data.resourceType;
this.operationType = data.operationType;
this.content = data.content;
if (this.content) {
try {
this.parsedContent = JSON.parse(this.content);
} catch (error) {
//TODO Handle this error
}
}
this.partitionKeyProperty = container && container.partitionKeyProperty;
this.partitionKey = container && container.partitionKey;
this.partitionKeyValue = extractPartitionKey(this.parsedContent, this.partitionKey as any);
this.stringPartitionKeyValue = this.getPartitionKeyValueAsString();
this.id = ko.observable(data.id);
this.isDirty = ko.observable(false);
}
public click() {
if (
!this.container.isEditorDirty() ||
window.confirm("Your unsaved changes will be lost. Do you want to continue?")
) {
this.loadConflict();
}
return;
}
public loadConflict(): Q.Promise<any> {
const conflictsTab = this.container;
this.container.selectedConflictId(this);
if (this.operationType === Constants.ConflictOperationType.Create) {
this.container.initDocumentEditorForCreate(this, this.content);
return Q();
}
this.container.loadingConflictData(true);
return conflictsTab.documentClientUtility
.readDocument(this.container.collection, this.buildDocumentIdFromConflict(this.partitionKeyValue))
.then(
(currentDocumentContent: any) => {
this.container.loadingConflictData(false);
if (this.operationType === Constants.ConflictOperationType.Replace) {
this.container.initDocumentEditorForReplace(this, this.content, currentDocumentContent);
} else {
this.container.initDocumentEditorForDelete(this, currentDocumentContent);
}
},
(reason: any) => {
this.container.loadingConflictData(false);
// Document could be deleted
if (
reason &&
reason.code === Constants.HttpStatusCodes.NotFound &&
this.operationType === Constants.ConflictOperationType.Delete
) {
this.container.initDocumentEditorForNoOp(this);
return Q();
}
return Q.reject(reason);
}
);
}
public getPartitionKeyValueAsString(): string {
const partitionKeyValue = this.partitionKeyValue;
const typeOfPartitionKeyValue = typeof partitionKeyValue;
if (partitionKeyValue === undefined || partitionKeyValue === null) {
return "";
}
if (typeOfPartitionKeyValue === "string") {
return partitionKeyValue;
}
if (Array.isArray(partitionKeyValue)) {
return partitionKeyValue.join("");
}
return JSON.stringify(partitionKeyValue);
}
public buildDocumentIdFromConflict(partitionKeyValue: any): ViewModels.DocumentId {
const conflictDocumentRid = Constants.HashRoutePrefixes.docsWithIds(
this.container.collection.getDatabase().rid,
this.container.collection.rid,
this.resourceId
);
const partitionKeyValueResolved = partitionKeyValue || this.partitionKeyValue;
let id = this.resourceId;
if (this.parsedContent) {
try {
id = this.parsedContent.id;
} catch (error) {
//TODO Handle this error
}
}
const documentId = new DocumentId(
null,
{
_rid: this.resourceId,
_self: conflictDocumentRid,
id,
partitionKeyValue: partitionKeyValueResolved,
partitionKeyProperty: this.partitionKeyProperty,
partitionKey: this.partitionKey
},
partitionKeyValueResolved
);
documentId.partitionKeyProperty = this.partitionKeyProperty;
documentId.partitionKey = this.partitionKey;
return documentId;
}
}

View File

@@ -0,0 +1,465 @@
import * as _ from "underscore";
import * as ko from "knockout";
import Q from "q";
import * as ViewModels from "../../Contracts/ViewModels";
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import DatabaseSettingsTab from "../Tabs/DatabaseSettingsTab";
import Collection from "./Collection";
import ContextMenu from "../Menus/ContextMenu";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { ContextMenuButtonFactory } from "../ContextMenuButtonFactory";
import { Logger } from "../../Common/Logger";
export default class Database implements ViewModels.Database {
public nodeKind: string;
public container: ViewModels.Explorer;
public self: string;
public rid: string;
public id: ko.Observable<string>;
public offer: ko.Observable<DataModels.Offer>;
public collections: ko.ObservableArray<Collection>;
public isDatabaseExpanded: ko.Observable<boolean>;
public isDatabaseShared: ko.Computed<boolean>;
public selectedSubnodeKind: ko.Observable<ViewModels.CollectionTabKind>;
public contextMenu: ViewModels.ContextMenu;
constructor(container: ViewModels.Explorer, data: any, offer: DataModels.Offer) {
this.nodeKind = "Database";
this.container = container;
this.self = data._self;
this.rid = data._rid;
this.id = ko.observable(data.id);
this.offer = ko.observable(offer);
this.collections = ko.observableArray<Collection>();
this.isDatabaseExpanded = ko.observable<boolean>(false);
this.contextMenu = new ContextMenu(this.container, this.rid);
this.contextMenu.options(
ContextMenuButtonFactory.createDatabaseContextMenuButton(container, { databaseId: this.id() })
);
this.selectedSubnodeKind = ko.observable<ViewModels.CollectionTabKind>();
this.isDatabaseShared = ko.pureComputed(() => {
return this.offer && !!this.offer();
});
}
public onKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.key === " " || event.key === "Enter") {
this.expandCollapseDatabase();
return false;
}
return true;
};
public onKeyDown = (source: any, event: KeyboardEvent): boolean => {
if (event.key === "Delete") {
this.onDeleteDatabaseContextMenuClick(source, event);
return false;
}
if (event.key === "ArrowRight") {
this.expandDatabase();
return false;
}
if (event.key === "ArrowLeft") {
this.collapseDatabase();
return false;
}
if (event.key === "Enter") {
this.expandCollapseDatabase();
return false;
}
return true;
};
public onMenuKeyDown = (source: any, event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
this.contextMenu.hide(source, event);
return false;
}
return true;
};
public onSettingsKeyDown = (source: any, event: KeyboardEvent): boolean => {
return true;
};
public onSettingsKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.key === " " || event.key === "Enter") {
this.onSettingsClick();
return false;
}
return true;
};
public onSettingsClick = () => {
this.container.selectedNode(this);
this.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
description: "Settings node",
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
// create settings tab if not created yet
const openedTabs = this.container.openedTabs();
let settingsTab: ViewModels.Tab = openedTabs
.filter(tab => tab.rid === this.rid)
.filter(tab => tab.tabKind === ViewModels.CollectionTabKind.DatabaseSettings)[0];
const pendingNotificationsPromise: Q.Promise<DataModels.Notification> = this._getPendingThroughputSplitNotification();
if (!settingsTab) {
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, {
databaseAccountName: this.container.databaseAccount().name,
databaseName: this.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: "Scale"
});
Q.all([pendingNotificationsPromise, this.readSettings()]).then(
(data: any) => {
const pendingNotification: DataModels.Notification = data && data[0];
settingsTab = new DatabaseSettingsTab({
tabKind: ViewModels.CollectionTabKind.DatabaseSettings,
title: "Scale",
tabPath: "",
documentClientUtility: this.container.documentClientUtility,
node: this,
rid: this.rid,
database: this,
hashLocation: `${Constants.HashRoutePrefixes.databasesWithId(this.id())}/settings`,
selfLink: this.self,
isActive: ko.observable(false),
onLoadStartKey: startKey,
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
openedTabs: this.container.openedTabs()
});
(settingsTab as ViewModels.DatabaseSettingsTab).pendingNotification(pendingNotification);
this.container.openedTabs.push(settingsTab);
settingsTab.onTabClick(); // Activate
},
(error: any) => {
TelemetryProcessor.traceFailure(
Action.Tab,
{
databaseAccountName: this.container.databaseAccount().name,
databaseName: this.id(),
collectionName: this.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: "Scale",
error: error
},
startKey
);
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while fetching database settings for database ${this.id()}: ${JSON.stringify(error)}`
);
throw error;
}
);
} else {
pendingNotificationsPromise.then(
(pendingNotification: DataModels.Notification) => {
(settingsTab as ViewModels.DatabaseSettingsTab).pendingNotification(pendingNotification);
settingsTab.onTabClick();
},
(error: any) => {
(settingsTab as ViewModels.DatabaseSettingsTab).pendingNotification(undefined);
settingsTab.onTabClick();
}
);
}
};
public readSettings(): Q.Promise<void> {
const deferred: Q.Deferred<void> = Q.defer<void>();
this.container.isRefreshingExplorer(true);
const databaseDataModel: DataModels.Database = <DataModels.Database>{
id: this.id(),
_rid: this.rid,
_self: this.self
};
const startKey: number = TelemetryProcessor.traceStart(Action.LoadOffers, {
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience()
});
const offerInfoPromise: Q.Promise<DataModels.Offer[]> = this.container.documentClientUtility.readOffers();
Q.all([offerInfoPromise]).then(
() => {
this.container.isRefreshingExplorer(false);
const databaseOffer: DataModels.Offer = this._getOfferForDatabase(
offerInfoPromise.valueOf(),
databaseDataModel
);
this.container.documentClientUtility
.readOffer(databaseOffer)
.then((offerDetail: DataModels.OfferWithHeaders) => {
const offerThroughputInfo: DataModels.OfferThroughputInfo = {
minimumRUForCollection:
offerDetail.content &&
offerDetail.content.collectionThroughputInfo &&
offerDetail.content.collectionThroughputInfo.minimumRUForCollection,
numPhysicalPartitions:
offerDetail.content &&
offerDetail.content.collectionThroughputInfo &&
offerDetail.content.collectionThroughputInfo.numPhysicalPartitions
};
databaseOffer.content.collectionThroughputInfo = offerThroughputInfo;
(databaseOffer as DataModels.OfferWithHeaders).headers = offerDetail.headers;
this.offer(databaseOffer);
this.offer.valueHasMutated();
TelemetryProcessor.traceSuccess(
Action.LoadOffers,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience()
},
startKey
);
deferred.resolve();
});
},
(error: any) => {
this.container.isRefreshingExplorer(false);
deferred.reject(error);
TelemetryProcessor.traceFailure(
Action.LoadOffers,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience()
},
startKey
);
}
);
return deferred.promise;
}
public isDatabaseNodeSelected(): boolean {
return (
!this.isDatabaseExpanded() &&
this.container.selectedNode &&
this.container.selectedNode() &&
this.container.selectedNode().rid === this.rid &&
this.container.selectedNode().nodeKind === "Database"
);
}
public onDeleteDatabaseContextMenuClick(source: ViewModels.Database, event: MouseEvent | KeyboardEvent) {
source.container.selectedNode(source);
source.contextMenu.hide(source, event);
this.container.deleteDatabaseConfirmationPane.open();
}
public selectDatabase() {
this.container.selectedNode(this);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
description: "Database node",
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
}
public expandCollapseDatabase() {
this.selectDatabase();
if (this.isDatabaseExpanded()) {
this.collapseDatabase();
} else {
this.expandDatabase();
}
this.container.onUpdateTabsButtons([]);
this.refreshTabSelectedState();
}
public expandDatabase() {
if (this.isDatabaseExpanded()) {
return;
}
this.loadCollections();
this.isDatabaseExpanded(true);
TelemetryProcessor.trace(Action.ExpandTreeNode, ActionModifiers.Mark, {
description: "Database node",
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
}
public collapseDatabase() {
if (!this.isDatabaseExpanded()) {
return;
}
this.isDatabaseExpanded(false);
TelemetryProcessor.trace(Action.CollapseTreeNode, ActionModifiers.Mark, {
description: "Database node",
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
}
public loadCollections(): Q.Promise<void> {
let collectionVMs: Collection[] = [];
let deferred: Q.Deferred<void> = Q.defer<void>();
this.container.documentClientUtility.readCollections(this).then(
(collections: DataModels.Collection[]) => {
let collectionsToAddVMPromises: Q.Promise<any>[] = [];
let deltaCollections = this.getDeltaCollections(collections);
deltaCollections.toAdd.forEach((collection: DataModels.Collection) => {
const collectionVM: Collection = new Collection(this.container, this.id(), collection, null, null);
collectionVMs.push(collectionVM);
});
//merge collections
this.addCollectionsToList(collectionVMs);
this.deleteCollectionsFromList(deltaCollections.toDelete);
deferred.resolve();
},
(error: any) => {
deferred.reject(error);
}
);
return deferred.promise;
}
public openAddCollection(database: Database, event: MouseEvent) {
database.contextMenu.hide(database, event);
database.container.addCollectionPane.databaseId(database.id());
database.container.addCollectionPane.open();
}
public refreshTabSelectedState(): void {
const openedRelevantTabs: ViewModels.Tab[] = this.container
.openedTabs()
.filter((tab: ViewModels.Tab) => tab && tab.collection && tab.collection.getDatabase().rid === this.rid);
openedRelevantTabs.forEach((tab: ViewModels.Tab) => {
if (tab.isActive()) {
tab.onTabClick(); // this ensures the next (deepest) item in the resource tree is highlighted
}
});
}
public findCollectionWithId(collectionId: string): ViewModels.Collection {
return _.find(this.collections(), (collection: ViewModels.Collection) => collection.id() === collectionId);
}
private _getPendingThroughputSplitNotification(): Q.Promise<DataModels.Notification> {
if (!this.container) {
return Q.resolve(undefined);
}
const deferred: Q.Deferred<DataModels.Notification> = Q.defer<DataModels.Notification>();
this.container.notificationsClient.fetchNotifications().then(
(notifications: DataModels.Notification[]) => {
if (!notifications || notifications.length === 0) {
deferred.resolve(undefined);
return;
}
const pendingNotification = _.find(notifications, (notification: DataModels.Notification) => {
const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress");
return (
notification.kind === "message" &&
!notification.collectionName &&
notification.databaseName === this.id() &&
notification.description &&
throughputUpdateRegExp.test(notification.description)
);
});
deferred.resolve(pendingNotification);
},
(error: any) => {
Logger.logError(
JSON.stringify({
error: JSON.stringify(error),
accountName: this.container && this.container.databaseAccount(),
databaseName: this.id(),
collectionName: this.id()
}),
"Settings tree node"
);
deferred.resolve(undefined);
}
);
return deferred.promise;
}
private getDeltaCollections(
updatedCollectionsList: DataModels.Collection[]
): { toAdd: DataModels.Collection[]; toDelete: Collection[] } {
const collectionsToAdd: DataModels.Collection[] = _.filter(
updatedCollectionsList,
(collection: DataModels.Collection) => {
const collectionExists = _.some(
this.collections(),
(existingCollection: Collection) => existingCollection.rid === collection._rid
);
return !collectionExists;
}
);
let collectionsToDelete: Collection[] = [];
ko.utils.arrayForEach(this.collections(), (collection: Collection) => {
const collectionPresentInUpdatedList = _.some(
updatedCollectionsList,
(coll: DataModels.Collection) => coll._rid === collection.rid
);
if (!collectionPresentInUpdatedList) {
collectionsToDelete.push(collection);
}
});
return { toAdd: collectionsToAdd, toDelete: collectionsToDelete };
}
private addCollectionsToList(collections: Collection[]): void {
this.collections(
this.collections()
.concat(collections)
.sort((collection1, collection2) => collection1.id().localeCompare(collection2.id()))
);
}
private deleteCollectionsFromList(collectionsToRemove: Collection[]): void {
const collectionsToKeep: Collection[] = [];
ko.utils.arrayForEach(this.collections(), (collection: Collection) => {
const shouldRemoveCollection = _.some(collectionsToRemove, (coll: Collection) => coll.rid === collection.rid);
if (!shouldRemoveCollection) {
collectionsToKeep.push(collection);
}
});
this.collections(collectionsToKeep);
}
private _getOfferForDatabase(offers: DataModels.Offer[], database: DataModels.Database): DataModels.Offer {
return _.find(offers, (offer: DataModels.Offer) => offer.resource === database._self);
}
}

View File

@@ -0,0 +1,110 @@
<div class="pointerCursor">
<div
role="treeitem"
tabindex="0"
data-test="databaseMenu"
class="databaseMenu treeHovermargin highlight"
data-bind="
click: $data.expandCollapseDatabase,
event: {
keydown: onKeyDown,
keypress: onKeyPress,
contextmenu: $data.contextMenu.show
},
clickBubble: false,
contextmenuBubble: false,
css:{
contextmenushowing: $data.contextMenu.visible,
highlight: true,
databaseNodeSelected: isDatabaseNodeSelected()
},
attr:{
'aria-expanded': $data.isDatabaseExpanded,
'aria-selected': isDatabaseNodeSelected()
}"
>
<span
class="databaseId databaseCollChildTextOverflow"
data-bind="
attr: {
title: $data.id()
}"
>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-right.svg"
alt="Show database properties"
data-bind="visible: !$data.isDatabaseExpanded()"
/>
<img
class="imgiconwidth collectionsTreeCollapseExpand"
src="/Triangle-down.svg"
alt="Hide database properties"
data-bind="visible: $data.isDatabaseExpanded()"
/>
<img src="/Azure-Cosmos-DB.svg" alt="Database" />
<!--ko text: $data.id--><!--/ko-->
</span>
<span
class="menuEllipsis"
data-test="databaseEllipsisMenu"
name="context menu"
role="button"
data-bind="
click: $data.contextMenu.show,
clickBubble: false
"
>&hellip;</span
>
</div>
<div class="databaseList" data-test="databaseList" data-bind="visible: $data.isDatabaseExpanded">
<!-- Scale & Setings Node - Start -->
<div data-bind="visible: $data.isDatabaseShared">
<div
role="treeitem"
class="databaseCollChildTextOverflow treeHovermargin highlight"
tabindex="0"
data-bind="
click: $data.onSettingsClick,
event: {
keydown: onSettingsKeyDown,
keypress: onSettingsKeyPress
},
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 11
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid && $data.selectedSubnodeKind() === 11
}"
>
<span class="databaseDocuments"> Scale </span>
</div>
</div>
<!-- Scale & Setings Node - End -->
<div data-bind="foreach: $data.collections">
<collection-node params="{data: $data}"></collection-node>
<collection-node-context-menu params="{data: $data}"></collection-node-context-menu>
</div>
</div>
<!-- Database Context Menu - Start -->
<div data-bind="event: { keydown: onMenuKeyDown }">
<div
class="context-menu-background"
data-bind="
visible: $data.contextMenu.visible,
click: $data.contextMenu.hide"
></div>
<div
class="context-menu"
data-test="databaseContextMenu"
data-bind="attr:{ tabindex: $data.contextMenu.tabIndex, id: $data.contextMenu.elementId }, visible: $data.contextMenu.visible, foreach: $data.contextMenu.options"
>
<command-button class="context-menu-option" params="{buttonProps: $data}"></command-button>
</div>
</div>
<!-- Database Context Menu - End -->
</div>

View File

@@ -0,0 +1,71 @@
import * as ko from "knockout";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
export default class DocumentId implements ViewModels.DocumentId {
public container: ViewModels.DocumentsTab;
public rid: string;
public self: string;
public ts: string;
public id: ko.Observable<string>;
public partitionKeyProperty: string;
public partitionKey: DataModels.PartitionKey;
public partitionKeyValue: any;
public stringPartitionKeyValue: string;
public isDirty: ko.Observable<boolean>;
constructor(container: ViewModels.DocumentsTab, data: any, partitionKeyValue: any) {
this.container = container;
this.self = data._self;
this.rid = data._rid;
this.ts = data._ts;
this.partitionKeyValue = partitionKeyValue;
this.partitionKeyProperty = container && container.partitionKeyProperty;
this.partitionKey = container && container.partitionKey;
this.stringPartitionKeyValue = this.getPartitionKeyValueAsString();
this.id = ko.observable(data.id);
this.isDirty = ko.observable(false);
}
public click() {
if (!this.container.isEditorDirty() || window.confirm("Your unsaved changes will be lost.")) {
this.loadDocument();
}
return;
}
public partitionKeyHeader(): Object {
if (!this.partitionKeyProperty) {
return undefined;
}
if (this.partitionKeyValue === undefined) {
return [{}];
}
return [this.partitionKeyValue];
}
public getPartitionKeyValueAsString(): string {
const partitionKeyValue: any = this.partitionKeyValue;
const typeOfPartitionKeyValue: string = typeof partitionKeyValue;
if (
typeOfPartitionKeyValue === "undefined" ||
typeOfPartitionKeyValue === "null" ||
typeOfPartitionKeyValue === "object"
) {
return "";
}
if (typeOfPartitionKeyValue === "string") {
return partitionKeyValue;
}
return JSON.stringify(partitionKeyValue);
}
public loadDocument(): Q.Promise<any> {
return this.container.selectDocument(this);
}
}

View File

@@ -0,0 +1,14 @@
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import DocumentId from "./DocumentId";
export default class ObjectId extends DocumentId implements ViewModels.DocumentId {
constructor(container: ViewModels.DocumentsTab, data: any, partitionKeyValue: any) {
super(container, data, partitionKeyValue);
if (typeof data._id === "object") {
this.id = ko.observable(data._id[Object.keys(data._id)[0]]);
} else {
this.id = ko.observable(data._id);
}
}
}

View File

@@ -0,0 +1,183 @@
import * as ko from "knockout";
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import DocumentId from "./DocumentId";
import DocumentsTab from "../Tabs/DocumentsTab";
import Q from "q";
import QueryTab from "../Tabs/QueryTab";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
export default class ResourceTokenCollection implements ViewModels.CollectionBase {
public nodeKind: string;
public container: ViewModels.Explorer;
public databaseId: string;
public self: string;
public rid: string;
public rawDataModel: DataModels.Collection;
public partitionKey: DataModels.PartitionKey;
public partitionKeyProperty: string;
public partitionKeyPropertyHeader: string;
public id: ko.Observable<string>;
public children: ko.ObservableArray<ViewModels.TreeNode>;
public selectedSubnodeKind: ko.Observable<ViewModels.CollectionTabKind>;
public isCollectionExpanded: ko.Observable<boolean>;
constructor(container: ViewModels.Explorer, databaseId: string, data: DataModels.Collection) {
this.nodeKind = "Collection";
this.container = container;
this.databaseId = databaseId;
this.self = data._self;
this.rid = data._rid;
this.rawDataModel = data;
this.partitionKey = data.partitionKey;
this.id = ko.observable(data.id);
this.children = ko.observableArray<ViewModels.TreeNode>([]);
this.selectedSubnodeKind = ko.observable<ViewModels.CollectionTabKind>();
this.isCollectionExpanded = ko.observable<boolean>(true);
}
public expandCollection(): Q.Promise<void> {
if (this.isCollectionExpanded()) {
return Q();
}
this.isCollectionExpanded(true);
TelemetryProcessor.trace(Action.ExpandTreeNode, ActionModifiers.Mark, {
description: "Collection node",
databaseAccountName: this.container.databaseAccount().name,
databaseName: this.databaseId,
collectionName: this.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
return Q.resolve();
}
public collapseCollection() {
if (!this.isCollectionExpanded()) {
return;
}
this.isCollectionExpanded(false);
TelemetryProcessor.trace(Action.CollapseTreeNode, ActionModifiers.Mark, {
description: "Collection node",
databaseAccountName: this.container.databaseAccount().name,
databaseName: this.databaseId,
collectionName: this.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
}
public refreshActiveTab(): void {
// ensures that the tab selects/highlights the right node based on resource tree expand/collapse state
const openedRelevantTabs: ViewModels.Tab[] = this.container
.openedTabs()
.filter((tab: ViewModels.Tab) => tab && tab.collection && tab.collection.rid === this.rid);
openedRelevantTabs.forEach((tab: ViewModels.Tab) => {
if (tab.isActive()) {
tab.onActivate();
}
});
}
public onNewQueryClick(source: any, event: MouseEvent, queryText?: string) {
const collection: ViewModels.Collection = source.collection || source;
const explorer: ViewModels.Explorer = source.container;
const openedTabs = explorer.openedTabs();
const id = openedTabs.filter(t => t.tabKind === ViewModels.CollectionTabKind.Query).length + 1;
const title = "Query " + id;
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, {
databaseAccountName: this.container.databaseAccount().name,
databaseName: this.databaseId,
collectionName: this.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: title
});
let queryTab: ViewModels.Tab = new QueryTab({
tabKind: ViewModels.CollectionTabKind.Query,
title: title,
tabPath: "",
documentClientUtility: this.container.documentClientUtility,
collection: this,
node: this,
selfLink: this.self,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(this.databaseId, this.id())}/query`,
isActive: ko.observable(false),
queryText: queryText,
partitionKey: collection.partitionKey,
resourceTokenPartitionKey: this.container.resourceTokenPartitionKey(),
onLoadStartKey: startKey,
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
openedTabs: this.container.openedTabs()
});
this.container.openedTabs.push(queryTab);
// Activate
queryTab.onTabClick();
}
public onDocumentDBDocumentsClick() {
this.container.selectedNode(this);
this.selectedSubnodeKind(ViewModels.CollectionTabKind.Documents);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
description: "Documents node",
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,
databaseName: this.databaseId,
collectionName: this.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
// create documents tab if not created yet
const openedTabs = this.container.openedTabs();
let documentsTab: ViewModels.Tab = openedTabs
.filter(tab => tab.collection && tab.collection.rid === this.rid)
.filter(tab => tab.tabKind === ViewModels.CollectionTabKind.Documents)[0];
if (!documentsTab) {
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, {
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,
databaseName: this.databaseId,
collectionName: this.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: "Items"
});
documentsTab = new DocumentsTab({
partitionKey: this.partitionKey,
resourceTokenPartitionKey: this.container.resourceTokenPartitionKey(),
documentIds: ko.observableArray<DocumentId>([]),
tabKind: ViewModels.CollectionTabKind.Documents,
title: "Items",
documentClientUtility: this.container.documentClientUtility,
selfLink: this.self,
isActive: ko.observable<boolean>(false),
collection: this,
node: this,
tabPath: `${this.databaseId}>${this.id()}>Documents`,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(this.databaseId, this.id())}/documents`,
onLoadStartKey: startKey,
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
openedTabs: this.container.openedTabs()
});
this.container.openedTabs.push(documentsTab);
}
// Activate
documentsTab.onTabClick();
}
public getDatabase(): ViewModels.Database {
return this.container.findDatabaseWithId(this.databaseId);
}
}

View File

@@ -0,0 +1,11 @@
<div
class="collectionstree"
data-test="resoureTree-collectionsTree"
tabindex="0"
role="tree"
data-bind="attr: { 'aria-label': collectionTitle }"
>
<div class="databaseList" data-bind="foreach: nonSystemDatabases">
<database-node params="{data: $data}"></database-node>
</div>
</div>

View File

@@ -0,0 +1,803 @@
import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { AccordionComponent, AccordionItemComponent } from "../Controls/Accordion/AccordionComponent";
import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent";
import * as ViewModels from "../../Contracts/ViewModels";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { ResourceTreeContextMenuButtonFactory } from "../ContextMenuButtonFactory";
import NotebookTab from "../Tabs/NotebookTab";
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
import { CosmosClient } from "../../Common/CosmosClient";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import CollectionIcon from "../../../images/tree-collection.svg";
import DeleteIcon from "../../../images/delete.svg";
import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
import RefreshIcon from "../../../images/refresh-cosmos.svg";
import { IGitHubRepo, IGitHubBranch } from "../../GitHub/GitHubClient";
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
import FileIcon from "../../../images/notebook/file-cosmos.svg";
import { ArrayHashMap } from "../../Common/ArrayHashMap";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import _ from "underscore";
import { StringUtils } from "../../Utils/StringUtils";
import { JunoClient, IPinnedRepo } from "../../Juno/JunoClient";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas } from "../../Common/Constants";
import { GitHubUtils } from "../../Utils/GitHubUtils";
export class ResourceTreeAdapter implements ReactAdapter {
private static readonly DataTitle = "DATA";
private static readonly NotebooksTitle = "NOTEBOOKS";
private static readonly SamplesRepo: IGitHubRepo = {
name: "cosmos-notebooks",
owner: {
login: "Azure-Samples"
},
private: false
};
private static readonly SamplesBranch: IGitHubBranch = {
name: "master"
};
private static readonly PseudoDirPath = "PsuedoDir";
public parameters: ko.Observable<number>;
public sampleNotebooksContentRoot: NotebookContentItem;
public myNotebooksContentRoot: NotebookContentItem;
public gitHubNotebooksContentRoot: NotebookContentItem;
private pinnedReposSubscription: ko.Subscription;
private koSubsDatabaseIdMap: ArrayHashMap<ko.Subscription>; // database id -> ko subs
private koSubsCollectionIdMap: ArrayHashMap<ko.Subscription>; // collection id -> ko subs
private databaseCollectionIdMap: ArrayHashMap<string>; // database id -> collection ids
public constructor(private container: ViewModels.Explorer, private junoClient: JunoClient) {
this.parameters = ko.observable(Date.now());
this.container.selectedNode.subscribe((newValue: any) => this.triggerRender());
this.container.activeTab.subscribe((newValue: ViewModels.Tab) => this.triggerRender());
this.container.isNotebookEnabled.subscribe(newValue => this.triggerRender());
this.koSubsDatabaseIdMap = new ArrayHashMap();
this.koSubsCollectionIdMap = new ArrayHashMap();
this.databaseCollectionIdMap = new ArrayHashMap();
this.container.nonSystemDatabases.subscribe((databases: ViewModels.Database[]) => {
// Clean up old databases
this.cleanupDatabasesKoSubs(databases.map((database: ViewModels.Database) => database.id()));
databases.forEach((database: ViewModels.Database) => this.watchDatabase(database));
this.triggerRender();
});
this.container.nonSystemDatabases().forEach((database: ViewModels.Database) => this.watchDatabase(database));
this.triggerRender();
}
public renderComponent(): JSX.Element {
const dataRootNode = this.buildDataTree();
const notebooksRootNode = this.buildNotebooksTrees();
if (this.container.isNotebookEnabled()) {
return (
<AccordionComponent>
<AccordionItemComponent title={ResourceTreeAdapter.DataTitle} isExpanded={!this.gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
</AccordionItemComponent>
<AccordionItemComponent title={ResourceTreeAdapter.NotebooksTitle}>
<TreeComponent className="notebookResourceTree" rootNode={notebooksRootNode} />
</AccordionItemComponent>
</AccordionComponent>
);
} else {
return <TreeComponent className="dataResourceTree" rootNode={dataRootNode} />;
}
}
public async initialize(): Promise<void[]> {
const refreshTasks: Promise<void>[] = [];
this.sampleNotebooksContentRoot = {
name: "Sample Notebooks (View Only)",
path: GitHubUtils.toGitHubUriForRepoAndBranch(
ResourceTreeAdapter.SamplesRepo.owner.login,
ResourceTreeAdapter.SamplesRepo.name,
ResourceTreeAdapter.SamplesBranch.name
),
type: NotebookContentItemType.Directory
};
refreshTasks.push(
this.container.refreshContentItem(this.sampleNotebooksContentRoot).then(() => this.triggerRender())
);
this.myNotebooksContentRoot = {
name: "My Notebooks",
path: this.container.getNotebookBasePath(),
type: NotebookContentItemType.Directory
};
// Only if notebook server is available we can refresh
if (this.container.notebookServerInfo().notebookServerEndpoint) {
refreshTasks.push(
this.container.refreshContentItem(this.myNotebooksContentRoot).then(() => this.triggerRender())
);
}
if (this.container.gitHubOAuthService?.isLoggedIn()) {
this.gitHubNotebooksContentRoot = {
name: "GitHub repos",
path: ResourceTreeAdapter.PseudoDirPath,
type: NotebookContentItemType.Directory
};
refreshTasks.push(this.refreshGitHubReposAndTriggerRender(this.gitHubNotebooksContentRoot));
} else {
this.gitHubNotebooksContentRoot = undefined;
}
return Promise.all(refreshTasks);
}
private async refreshGitHubReposAndTriggerRender(item: NotebookContentItem): Promise<void> {
const updateGitHubReposAndRender = (pinnedRepos: IPinnedRepo[]) => {
item.children = [];
pinnedRepos.forEach(pinnedRepo => {
const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name);
const repoTreeItem: NotebookContentItem = {
name: repoFullName,
path: ResourceTreeAdapter.PseudoDirPath,
type: NotebookContentItemType.Directory,
children: []
};
pinnedRepo.branches.forEach(branch => {
repoTreeItem.children.push({
name: branch.name,
path: GitHubUtils.toGitHubUriForRepoAndBranch(pinnedRepo.owner, pinnedRepo.name, branch.name),
type: NotebookContentItemType.Directory
});
});
item.children.push(repoTreeItem);
});
this.triggerRender();
};
if (this.pinnedReposSubscription) {
this.pinnedReposSubscription.dispose();
}
this.pinnedReposSubscription = this.junoClient.subscribeToPinnedRepos(pinnedRepos =>
updateGitHubReposAndRender(pinnedRepos)
);
await this.junoClient.getPinnedRepos(this.container.gitHubOAuthService?.getTokenObservable()()?.scope);
}
private buildDataTree(): TreeNode {
const databaseTreeNodes: TreeNode[] = this.container.nonSystemDatabases().map((database: ViewModels.Database) => {
const databaseNode: TreeNode = {
label: database.id(),
iconSrc: CosmosDBIcon,
isExpanded: false,
className: "databaseHeader",
children: [],
isSelected: () => this.isDataNodeSelected(database.rid, "Database", undefined),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(this.container, database),
onClick: isExpanded => {
// Rewritten version of expandCollapseDatabase():
if (!isExpanded) {
database.expandDatabase();
database.loadCollections();
} else {
database.collapseDatabase();
}
database.selectDatabase();
this.container.onUpdateTabsButtons([]);
database.refreshTabSelectedState();
},
onContextMenuOpen: () => this.container.selectedNode(database)
};
if (database.isDatabaseShared()) {
databaseNode.children.push({
label: "Scale",
isSelected: () =>
this.isDataNodeSelected(database.rid, "Database", ViewModels.CollectionTabKind.DatabaseSettings),
onClick: database.onSettingsClick.bind(database)
});
}
// Find collections
database
.collections()
.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(this.buildCollectionNode(database, collection))
);
return databaseNode;
});
return {
label: undefined,
isExpanded: true,
children: databaseTreeNodes
};
}
/**
* This is a rewrite of Collection.ts : showScriptsMenu, showStoredProcedures, showTriggers, showUserDefinedFunctions
* @param container
*/
private static showScriptNodes(container: ViewModels.Explorer): boolean {
return container.isPreferredApiDocumentDB() || container.isPreferredApiGraph();
}
private buildCollectionNode(database: ViewModels.Database, collection: ViewModels.Collection): TreeNode {
const children: TreeNode[] = [];
children.push({
label: collection.getLabel(),
onClick: () => {
collection.openTab();
// push to most recent
this.container.mostRecentActivity.addItem(CosmosClient.databaseAccount().id, {
type: MostRecentActivity.Type.OpenCollection,
title: collection.id(),
description: "Data",
data: collection.rid
});
},
isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Documents),
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(this.container, collection)
});
children.push({
label: database.isDatabaseShared() ? "Settings" : "Scale & Settings",
onClick: collection.onSettingsClick.bind(collection),
isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Settings)
});
if (ResourceTreeAdapter.showScriptNodes(this.container)) {
children.push(this.buildStoredProcedureNode(collection));
children.push(this.buildUserDefinedFunctionsNode(collection));
children.push(this.buildTriggerNode(collection));
}
// This is a rewrite of showConflicts
const showConflicts =
this.container.databaseAccount &&
this.container.databaseAccount() &&
this.container.databaseAccount().properties &&
this.container.databaseAccount().properties.enableMultipleWriteLocations &&
collection.rawDataModel &&
!!collection.rawDataModel.conflictResolutionPolicy;
if (showConflicts) {
children.push({
label: "Conflicts",
onClick: collection.onConflictsClick.bind(collection),
isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Conflicts)
});
}
return {
label: collection.id(),
iconSrc: CollectionIcon,
isExpanded: false,
children: children,
className: "collectionHeader",
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(this.container, collection),
onClick: () => {
// Rewritten version of expandCollapseCollection
this.container.selectedNode(collection);
this.container.onUpdateTabsButtons([]);
collection.refreshActiveTab();
},
onExpanded: () => {
if (ResourceTreeAdapter.showScriptNodes(this.container)) {
collection.loadStoredProcedures();
collection.loadUserDefinedFunctions();
collection.loadTriggers();
}
},
isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", undefined),
onContextMenuOpen: () => this.container.selectedNode(collection)
};
}
private buildStoredProcedureNode(collection: ViewModels.Collection): TreeNode {
return {
label: "Stored Procedures",
children: collection.storedProcedures().map((sp: ViewModels.StoredProcedure) => ({
label: sp.id(),
onClick: sp.open.bind(sp),
isSelected: () =>
this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.StoredProcedures),
contextMenu: ResourceTreeContextMenuButtonFactory.createStoreProcedureContextMenuItems(this.container)
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.StoredProcedures);
collection.refreshActiveTab();
}
};
}
private buildUserDefinedFunctionsNode(collection: ViewModels.Collection): TreeNode {
return {
label: "User Defined Functions",
children: collection.userDefinedFunctions().map((udf: ViewModels.UserDefinedFunction) => ({
label: udf.id(),
onClick: udf.open.bind(udf),
isSelected: () =>
this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.UserDefinedFunctions),
contextMenu: ResourceTreeContextMenuButtonFactory.createUserDefinedFunctionContextMenuItems(this.container)
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.UserDefinedFunctions);
collection.refreshActiveTab();
}
};
}
private buildTriggerNode(collection: ViewModels.Collection): TreeNode {
return {
label: "Triggers",
children: collection.triggers().map((trigger: ViewModels.Trigger) => ({
label: trigger.id(),
onClick: trigger.open.bind(trigger),
isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Triggers),
contextMenu: ResourceTreeContextMenuButtonFactory.createTriggerContextMenuItems(this.container)
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Triggers);
collection.refreshActiveTab();
}
};
}
private buildNotebooksTrees(): TreeNode {
let notebooksTree: TreeNode = {
label: undefined,
isExpanded: true,
isLeavesParentsSeparate: true,
children: []
};
if (this.sampleNotebooksContentRoot) {
notebooksTree.children.push(this.buildSampleNotebooksTree());
}
if (this.myNotebooksContentRoot) {
notebooksTree.children.push(this.buildMyNotebooksTree());
}
if (this.gitHubNotebooksContentRoot) {
// collapse all other notebook nodes
notebooksTree.children.forEach(node => (node.isExpanded = false));
notebooksTree.children.push(this.buildGitHubNotebooksTree());
}
return notebooksTree;
}
private buildSampleNotebooksTree(): TreeNode {
const sampleNotebooksTree: TreeNode = this.buildNotebookDirectoryNode(
this.sampleNotebooksContentRoot,
(item: NotebookContentItem) => {
const databaseAccountName: string = this.container.databaseAccount() && this.container.databaseAccount().name;
const defaultExperience: string = this.container.defaultExperience && this.container.defaultExperience();
const dataExplorerArea: string = Areas.ResourceTree;
const startKey: number = TelemetryProcessor.traceStart(Action.OpenSampleNotebook, {
databaseAccountName,
defaultExperience,
dataExplorerArea
});
this.container.importAndOpen(item.path).then(hasOpened => {
if (hasOpened) {
this.pushItemToMostRecent(item);
TelemetryProcessor.traceSuccess(
Action.OpenSampleNotebook,
{
databaseAccountName,
defaultExperience,
dataExplorerArea
},
startKey
);
} else {
TelemetryProcessor.traceFailure(
Action.OpenSampleNotebook,
{
databaseAccountName,
defaultExperience,
dataExplorerArea
},
startKey
);
}
});
},
false,
false
);
sampleNotebooksTree.isExpanded = true;
// Remove children starting with "."
sampleNotebooksTree.children = sampleNotebooksTree.children.filter(
node => !StringUtils.startsWith(node.label, ".")
);
return sampleNotebooksTree;
}
private buildMyNotebooksTree(): TreeNode {
const myNotebooksTree: TreeNode = this.buildNotebookDirectoryNode(
this.myNotebooksContentRoot,
(item: NotebookContentItem) => {
this.container.openNotebook(item).then(hasOpened => {
if (hasOpened) {
this.pushItemToMostRecent(item);
}
});
},
true,
true
);
myNotebooksTree.isExpanded = true;
myNotebooksTree.isAlphaSorted = true;
// Remove "Delete" menu item from context menu
myNotebooksTree.contextMenu = myNotebooksTree.contextMenu.filter(menuItem => menuItem.label !== "Delete");
return myNotebooksTree;
}
private buildGitHubNotebooksTree(): TreeNode {
const gitHubNotebooksTree: TreeNode = this.buildNotebookDirectoryNode(
this.gitHubNotebooksContentRoot,
(item: NotebookContentItem) => {
this.container.openNotebook(item).then(hasOpened => {
if (hasOpened) {
this.pushItemToMostRecent(item);
}
});
},
true,
true
);
gitHubNotebooksTree.contextMenu = [
{
label: "Manage GitHub settings",
onClick: () => this.container.gitHubReposPane.open()
},
{
label: "Disconnect from GitHub",
onClick: () => {
TelemetryProcessor.trace(Action.NotebooksGitHubDisconnect, ActionModifiers.Mark, {
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience && this.container.defaultExperience(),
dataExplorerArea: Areas.Notebook
});
this.container.gitHubOAuthService.logout();
}
}
];
gitHubNotebooksTree.isExpanded = true;
gitHubNotebooksTree.isAlphaSorted = true;
return gitHubNotebooksTree;
}
private pushItemToMostRecent(item: NotebookContentItem) {
this.container.mostRecentActivity.addItem(CosmosClient.databaseAccount().id, {
type: MostRecentActivity.Type.OpenNotebook,
title: item.name,
description: "Notebook",
data: {
name: item.name,
path: item.path
}
});
}
private buildChildNodes(
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
createDirectoryContextMenu: boolean,
createFileContextMenu: boolean
): TreeNode[] {
if (!item || !item.children) {
return [];
} else {
return item.children.map(item => {
const result =
item.type === NotebookContentItemType.Directory
? this.buildNotebookDirectoryNode(item, onFileClick, createDirectoryContextMenu, createFileContextMenu)
: this.buildNotebookFileNode(item, onFileClick, createFileContextMenu);
result.timestamp = item.timestamp;
return result;
});
}
}
private buildNotebookFileNode(
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
createFileContextMenu: boolean
): TreeNode {
return {
label: item.name,
iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon,
className: "notebookHeader",
onClick: () => onFileClick(item),
isSelected: () => {
const activeTab = this.container.findActiveTab();
return (
activeTab &&
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
(activeTab as NotebookTab).notebookPath() === item.path
);
},
contextMenu: createFileContextMenu
? [
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => this.container.renameNotebook(item)
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
this.container.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}"`,
"Delete",
() => this.container.deleteNotebookFile(item).then(() => this.triggerRender()),
"Cancel",
undefined
);
}
},
{
label: "Download",
iconSrc: NotebookIcon,
onClick: () => this.container.downloadFile(item)
}
]
: undefined,
data: item
};
}
private createDirectoryContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] {
let items: TreeNodeMenuItem[] = [
{
label: "Refresh",
iconSrc: RefreshIcon,
onClick: () => this.container.refreshContentItem(item).then(() => this.triggerRender())
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
this.container.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}?"`,
"Delete",
() => this.container.deleteNotebookFile(item).then(() => this.triggerRender()),
"Cancel",
undefined
);
}
},
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => this.container.renameNotebook(item).then(() => this.triggerRender())
},
{
label: "New Directory",
iconSrc: NewNotebookIcon,
onClick: () => this.container.onCreateDirectory(item)
},
{
label: "New Notebook",
iconSrc: NewNotebookIcon,
onClick: () => this.container.onNewNotebookClicked(item)
},
{
label: "Upload File",
iconSrc: NewNotebookIcon,
onClick: () => this.container.onUploadToNotebookServerClicked(item)
}
];
// For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File"
if (GitHubUtils.fromGitHubUri(item.path)) {
items = items.filter(
item =>
item.label !== "Delete" &&
item.label !== "Rename" &&
item.label !== "New Directory" &&
item.label !== "Upload File"
);
}
return items;
}
private buildNotebookDirectoryNode(
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
createDirectoryContextMenu: boolean,
createFileContextMenu: boolean
): TreeNode {
return {
label: item.name,
iconSrc: undefined,
className: "notebookHeader",
isAlphaSorted: true,
isLeavesParentsSeparate: true,
onClick: () => {
if (!item.children) {
this.container.refreshContentItem(item).then(() => this.triggerRender());
}
},
isSelected: () => {
const activeTab = this.container.findActiveTab();
return (
activeTab &&
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
(activeTab as NotebookTab).notebookPath() === item.path
);
},
contextMenu:
createDirectoryContextMenu && item.path !== ResourceTreeAdapter.PseudoDirPath
? this.createDirectoryContextMenu(item)
: undefined,
data: item,
children: this.buildChildNodes(item, onFileClick, createDirectoryContextMenu, createFileContextMenu)
};
}
public triggerRender() {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
private getActiveTab(): ViewModels.Tab {
const activeTabs: ViewModels.Tab[] = this.container.openedTabs().filter((tab: ViewModels.Tab) => tab.isActive());
if (activeTabs.length) {
return activeTabs[0];
}
return undefined;
}
private isDataNodeSelected(rid: string, nodeKind: string, subnodeKind: ViewModels.CollectionTabKind): boolean {
if (!this.container.selectedNode || !this.container.selectedNode()) {
return false;
}
const selectedNode = this.container.selectedNode();
if (subnodeKind === undefined) {
return selectedNode.rid === rid && selectedNode.nodeKind === nodeKind;
} else {
const activeTab = this.getActiveTab();
let selectedSubnodeKind;
if (nodeKind === "Database" && (selectedNode as ViewModels.Database).selectedSubnodeKind) {
selectedSubnodeKind = (selectedNode as ViewModels.Database).selectedSubnodeKind();
} else if (nodeKind === "Collection" && (selectedNode as ViewModels.Collection).selectedSubnodeKind) {
selectedSubnodeKind = (selectedNode as ViewModels.Collection).selectedSubnodeKind();
}
return (
activeTab &&
activeTab.tabKind === subnodeKind &&
selectedNode.rid === rid &&
selectedSubnodeKind !== undefined &&
selectedSubnodeKind === subnodeKind
);
}
}
// *************** watch all nested ko's inside database
// TODO Simplify so we don't have to do this
private watchCollection(databaseId: string, collection: ViewModels.Collection) {
this.addKoSubToCollectionId(
databaseId,
collection.id(),
collection.storedProcedures.subscribe(() => {
this.triggerRender();
})
);
this.addKoSubToCollectionId(
databaseId,
collection.id(),
collection.isCollectionExpanded.subscribe(() => {
this.triggerRender();
})
);
this.addKoSubToCollectionId(
databaseId,
collection.id(),
collection.isStoredProceduresExpanded.subscribe(() => {
this.triggerRender();
})
);
}
private watchDatabase(database: ViewModels.Database) {
const databaseId = database.id();
const koSub = database.collections.subscribe((collections: ViewModels.Collection[]) => {
this.cleanupCollectionsKoSubs(
databaseId,
collections.map((collection: ViewModels.Collection) => collection.id())
);
collections.forEach((collection: ViewModels.Collection) => this.watchCollection(databaseId, collection));
this.triggerRender();
});
this.addKoSubToDatabaseId(databaseId, koSub);
database.collections().forEach((collection: ViewModels.Collection) => this.watchCollection(databaseId, collection));
}
private addKoSubToDatabaseId(databaseId: string, sub: ko.Subscription): void {
this.koSubsDatabaseIdMap.push(databaseId, sub);
}
private addKoSubToCollectionId(databaseId: string, collectionId: string, sub: ko.Subscription): void {
this.databaseCollectionIdMap.push(databaseId, collectionId);
this.koSubsCollectionIdMap.push(collectionId, sub);
}
private cleanupDatabasesKoSubs(existingDatabaseIds: string[]): void {
const databaseIdsToRemove = this.databaseCollectionIdMap
.keys()
.filter((id: string) => existingDatabaseIds.indexOf(id) === -1);
databaseIdsToRemove.forEach((databaseId: string) => {
if (this.koSubsDatabaseIdMap.has(databaseId)) {
this.koSubsDatabaseIdMap.get(databaseId).forEach((sub: ko.Subscription) => sub.dispose());
this.koSubsDatabaseIdMap.delete(databaseId);
}
if (this.databaseCollectionIdMap.has(databaseId)) {
this.databaseCollectionIdMap
.get(databaseId)
.forEach((collectionId: string) => this.cleanupKoSubsForCollection(databaseId, collectionId));
}
});
}
private cleanupCollectionsKoSubs(databaseId: string, existingCollectionIds: string[]): void {
if (!this.databaseCollectionIdMap.has(databaseId)) {
return;
}
const collectionIdsToRemove = this.databaseCollectionIdMap
.get(databaseId)
.filter((id: string) => existingCollectionIds.indexOf(id) === -1);
collectionIdsToRemove.forEach((id: string) => this.cleanupKoSubsForCollection(databaseId, id));
}
private cleanupKoSubsForCollection(databaseId: string, collectionId: string) {
if (!this.koSubsCollectionIdMap.has(collectionId)) {
return;
}
this.koSubsCollectionIdMap.get(collectionId).forEach((sub: ko.Subscription) => sub.dispose());
this.koSubsCollectionIdMap.delete(collectionId);
this.databaseCollectionIdMap.remove(databaseId, collectionId);
}
}

View File

@@ -0,0 +1,50 @@
import * as ko from "knockout";
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import React from "react";
import ResourceTokenCollection from "./ResourceTokenCollection";
import { ResourceTreeAdapterForResourceToken } from "./ResourceTreeAdapterForResourceToken";
import { shallow } from "enzyme";
import { TreeComponent, TreeNode, TreeComponentProps } from "../Controls/TreeComponent/TreeComponent";
const createMockContainer = (): ViewModels.Explorer => {
let mockContainer = {} as ViewModels.Explorer;
mockContainer.resourceTokenCollection = createMockCollection(mockContainer);
mockContainer.selectedNode = ko.observable<ViewModels.TreeNode>();
mockContainer.activeTab = ko.observable<ViewModels.Tab>();
mockContainer.mostRecentActivity = new MostRecentActivity.MostRecentActivity(mockContainer);
mockContainer.openedTabs = ko.observableArray<ViewModels.Tab>([]);
mockContainer.onUpdateTabsButtons = () => {};
return mockContainer;
};
const createMockCollection = (container: ViewModels.Explorer): ko.Observable<ViewModels.CollectionBase> => {
let mockCollection = {} as DataModels.Collection;
mockCollection._rid = "fakeRid";
mockCollection._self = "fakeSelf";
mockCollection.id = "fakeId";
const mockResourceTokenCollection: ViewModels.CollectionBase = new ResourceTokenCollection(
container,
"fakeDatabaseId",
mockCollection
);
return ko.observable<ViewModels.CollectionBase>(mockResourceTokenCollection);
};
describe("Resource tree for resource token", () => {
const mockContainer: ViewModels.Explorer = createMockContainer();
const resourceTree = new ResourceTreeAdapterForResourceToken(mockContainer);
it("should render", () => {
const rootNode: TreeNode = resourceTree.buildCollectionNode();
const props: TreeComponentProps = {
rootNode,
className: "dataResourceTree"
};
const wrapper = shallow(<TreeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,116 @@
import * as ko from "knockout";
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
import * as React from "react";
import * as ViewModels from "../../Contracts/ViewModels";
import { CosmosClient } from "../../Common/CosmosClient";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { TreeComponent, TreeNode } from "../Controls/TreeComponent/TreeComponent";
import CollectionIcon from "../../../images/tree-collection.svg";
export class ResourceTreeAdapterForResourceToken implements ReactAdapter {
public parameters: ko.Observable<number>;
public myNotebooksContentRoot: NotebookContentItem;
public constructor(private container: ViewModels.Explorer) {
this.parameters = ko.observable(Date.now());
this.container.resourceTokenCollection.subscribe((collection: ViewModels.CollectionBase) => this.triggerRender());
this.container.selectedNode.subscribe((newValue: any) => this.triggerRender());
this.container.activeTab.subscribe((newValue: ViewModels.Tab) => this.triggerRender());
this.triggerRender();
}
public renderComponent(): JSX.Element {
const dataRootNode = this.buildCollectionNode();
return <TreeComponent className="dataResourceTree" rootNode={dataRootNode} />;
}
public buildCollectionNode(): TreeNode {
const collection: ViewModels.CollectionBase = this.container.resourceTokenCollection();
if (!collection) {
return {
label: undefined,
isExpanded: true,
children: []
};
}
const children: TreeNode[] = [];
children.push({
label: "Items",
onClick: () => {
collection.onDocumentDBDocumentsClick();
// push to most recent
this.container.mostRecentActivity.addItem(CosmosClient.databaseAccount().id, {
type: MostRecentActivity.Type.OpenCollection,
title: collection.id(),
description: "Data",
data: collection.rid
});
},
isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Documents)
});
const collectionNode: TreeNode = {
label: collection.id(),
iconSrc: CollectionIcon,
isExpanded: true,
children,
className: "collectionHeader",
onClick: () => {
// Rewritten version of expandCollapseCollection
this.container.selectedNode(collection);
this.container.onUpdateTabsButtons([]);
collection.refreshActiveTab();
},
isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", undefined)
};
return {
label: undefined,
isExpanded: true,
children: [collectionNode]
};
}
private getActiveTab(): ViewModels.Tab {
const activeTabs: ViewModels.Tab[] = this.container.openedTabs().filter((tab: ViewModels.Tab) => tab.isActive());
if (activeTabs.length) {
return activeTabs[0];
}
return undefined;
}
private isDataNodeSelected(rid: string, nodeKind: string, subnodeKind: ViewModels.CollectionTabKind): boolean {
if (!this.container.selectedNode || !this.container.selectedNode()) {
return false;
}
const selectedNode = this.container.selectedNode();
if (subnodeKind) {
return selectedNode.rid === rid && selectedNode.nodeKind === nodeKind;
} else {
const activeTab = this.getActiveTab();
let selectedSubnodeKind;
if (nodeKind === "Database" && (selectedNode as ViewModels.Database).selectedSubnodeKind) {
selectedSubnodeKind = (selectedNode as ViewModels.Database).selectedSubnodeKind();
} else if (nodeKind === "Collection" && (selectedNode as ViewModels.Collection).selectedSubnodeKind) {
selectedSubnodeKind = (selectedNode as ViewModels.Collection).selectedSubnodeKind();
}
return (
activeTab &&
activeTab.tabKind === subnodeKind &&
selectedNode.rid === rid &&
selectedSubnodeKind !== undefined &&
selectedSubnodeKind === subnodeKind
);
}
}
public triggerRender() {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
}

View File

@@ -0,0 +1,239 @@
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import StoredProcedureTab from "../Tabs/StoredProcedureTab";
import ContextMenu from "../Menus/ContextMenu";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { ContextMenuButtonFactory } from "../ContextMenuButtonFactory";
const sampleStoredProcedureBody: string = `// SAMPLE STORED PROCEDURE
function sample(prefix) {
var collection = getContext().getCollection();
// Query documents and take 1st item.
var isAccepted = collection.queryDocuments(
collection.getSelfLink(),
'SELECT * FROM root r',
function (err, feed, options) {
if (err) throw err;
// Check the feed and if empty, set the body to 'no docs found', 
// else take 1st element from feed
if (!feed || !feed.length) {
var response = getContext().getResponse();
response.setBody('no docs found');
}
else {
var response = getContext().getResponse();
var body = { prefix: prefix, feed: feed[0] };
response.setBody(JSON.stringify(body));
}
});
if (!isAccepted) throw new Error('The query was not accepted by the server.');
}`;
export default class StoredProcedure implements ViewModels.StoredProcedure {
public nodeKind: string;
public container: ViewModels.Explorer;
public collection: ViewModels.Collection;
public self: string;
public rid: string;
public id: ko.Observable<string>;
public body: ko.Observable<string>;
public contextMenu: ViewModels.ContextMenu;
public isExecuteEnabled: boolean;
constructor(container: ViewModels.Explorer, collection: ViewModels.Collection, data: DataModels.StoredProcedure) {
this.nodeKind = "StoredProcedure";
this.container = container;
this.collection = collection;
this.self = data._self;
this.rid = data._rid;
this.id = ko.observable(data.id);
this.body = ko.observable(data.body);
this.isExecuteEnabled = this.container.isFeatureEnabled(Constants.Features.executeSproc);
this.contextMenu = new ContextMenu(this.container, this.rid);
this.contextMenu.options(ContextMenuButtonFactory.createStoreProcedureContextMenuButton(container));
}
public static create(source: ViewModels.Collection, event: MouseEvent) {
const openedTabs = source.container.openedTabs();
const id =
openedTabs.filter((tab: ViewModels.Tab) => tab.tabKind === ViewModels.CollectionTabKind.StoredProcedures).length +
1;
const storedProcedure = <DataModels.StoredProcedure>{
id: "",
body: sampleStoredProcedureBody
};
let storedProcedureTab: ViewModels.Tab = new StoredProcedureTab({
resource: storedProcedure,
isNew: true,
tabKind: ViewModels.CollectionTabKind.StoredProcedures,
title: `New Stored Procedure ${id}`,
tabPath: `${source.databaseId}>${source.id()}>New Stored Procedure ${id}`,
documentClientUtility: source.container.documentClientUtility,
collection: source,
node: source,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(source.databaseId, source.id())}/sproc`,
selfLink: "",
isActive: ko.observable(false),
onUpdateTabsButtons: source.container.onUpdateTabsButtons,
openedTabs: source.container.openedTabs()
});
source.container.openedTabs.push(storedProcedureTab);
// Activate
storedProcedureTab.onTabClick();
// Hide Context Menu (if necessary)
source.contextMenu.hide(source, event);
}
public select() {
this.container.selectedNode(this);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
description: "Stored procedure node",
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
}
public open = () => {
this.select();
const openedTabs = this.container.openedTabs();
const storedProcedureTabsOpen: ViewModels.Tab[] =
openedTabs &&
openedTabs.filter(
tab => tab.node && tab.node.rid === this.rid && tab.tabKind === ViewModels.CollectionTabKind.StoredProcedures
);
let storedProcedureTab: ViewModels.Tab =
storedProcedureTabsOpen && storedProcedureTabsOpen.length > 0 && storedProcedureTabsOpen[0];
if (!storedProcedureTab) {
const storedProcedureData = <DataModels.StoredProcedure>{
_rid: this.rid,
_self: this.self,
id: this.id(),
body: this.body()
};
storedProcedureTab = new StoredProcedureTab({
resource: storedProcedureData,
isNew: false,
tabKind: ViewModels.CollectionTabKind.StoredProcedures,
title: storedProcedureData.id,
tabPath: `${this.collection.databaseId}>${this.collection.id()}>${storedProcedureData.id}`,
documentClientUtility: this.container.documentClientUtility,
collection: this.collection,
node: this,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(
this.collection.databaseId,
this.collection.id()
)}/sprocs/${this.id()}`,
selfLink: this.self,
isActive: ko.observable(false),
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
openedTabs: this.container.openedTabs()
});
this.container.openedTabs.push(storedProcedureTab);
}
// Activate
storedProcedureTab.onTabClick();
};
public delete(source: ViewModels.Collection, event: MouseEvent | KeyboardEvent) {
// Hide Context Menu (if necessary)
this.contextMenu.hide(source, event);
if (!window.confirm("Are you sure you want to delete the stored procedure?")) {
return;
}
const storedProcedureData = <DataModels.StoredProcedure>{
_rid: this.rid,
_self: this.self,
id: this.id(),
body: this.body()
};
this.container.documentClientUtility.deleteStoredProcedure(this.collection, storedProcedureData).then(
() => {
this.container.openedTabs.remove((tab: ViewModels.Tab) => tab.node && tab.node.rid === this.rid);
this.collection.children.remove(this);
},
reason => {}
);
}
public execute(params: string[], partitionKeyValue?: string): void {
const sprocTab: ViewModels.StoredProcedureTab = this._getCurrentStoredProcedureTab();
sprocTab.isExecuting(true);
this.container &&
this.container.documentClientUtility
.executeStoredProcedure(this.collection, this, partitionKeyValue, params)
.then(
(result: any) => {
sprocTab.onExecuteSprocsResult(result, result.scriptLogs);
},
(error: any) => {
sprocTab.onExecuteSprocsError(JSON.stringify(error));
}
)
.finally(() => {
sprocTab.isExecuting(false);
this.onFocusAfterExecute();
});
}
public onKeyDown = (source: any, event: KeyboardEvent): boolean => {
if (event.key === "Delete") {
this.delete(source, event);
return false;
}
return true;
};
public onMenuKeyDown = (source: any, event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
this.contextMenu.hide(source, event);
return false;
}
return true;
};
public onKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.key === " " || event.key === "Enter") {
this.open();
return false;
}
return true;
};
public onFocusAfterExecute(): void {
const focusElement = document.getElementById("execute-storedproc-toggles");
focusElement && focusElement.focus();
}
private _getCurrentStoredProcedureTab(): ViewModels.StoredProcedureTab {
const openedTabs = this.container.openedTabs();
const storedProcedureTabsOpen: ViewModels.Tab[] =
openedTabs &&
openedTabs.filter(
tab => tab.node && tab.node.rid === this.rid && tab.tabKind === ViewModels.CollectionTabKind.StoredProcedures
);
return (storedProcedureTabsOpen &&
storedProcedureTabsOpen.length > 0 &&
storedProcedureTabsOpen[0]) as ViewModels.StoredProcedureTab;
}
}

View File

@@ -0,0 +1,63 @@
<div
role="treeitem"
tabindex="0"
class="pointerCursor"
data-bind="
click: $data.open,
event: {
keydown: onKeyDown,
keypress: onKeyPress,
contextmenu: $data.contextMenu.show
},
clickBubble: false,
contextmenuBubble: false,
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid,
contextmenushowing: $data.contextMenu.visible
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid
}"
>
<div class="storedChildMenu treeChildMenu">
<div
class="childMenu"
data-bind="
attr: {
title: $data.id()
}"
>
<!--ko text: $data.id-->
<!--/ko-->
</div>
<span
class="menuEllipsis"
name="context menu"
role="button"
data-bind="
click: $data.contextMenu.show,
clickBubble: false
"
>&hellip;</span
>
</div>
</div>
<!-- Stored Procedure Node Context Menu - Start -->
<div data-bind="event: { keydown: onMenuKeyDown }">
<div
class="context-menu-background"
data-bind="
visible: $data.contextMenu.visible,
click: $data.contextMenu.hide"
></div>
<div
class="context-menu"
data-bind="attr:{ tabindex: $data.contextMenu.tabIndex, id: $data.contextMenu.elementId }, visible: $data.contextMenu.visible, foreach: $data.contextMenu.options"
>
<command-button class="context-menu-option" params="{buttonProps: $data}"></command-button>
</div>
</div>
<!-- Stored Procedure Node Context Menu - End -->

View File

@@ -0,0 +1,76 @@
import resourceTreeTemplate from "./ResourceTree.html";
import databaseTreeNoteTemplate from "./DatabaseTreeNode.html";
import collectionTreeNodeTemplate from "./CollectionTreeNode.html";
import storedProcedureTreeNodeTemplate from "./StoredProcedureTreeNode.html";
import userDefinedFunctionTreeNodeTemplate from "./UserDefinedFunctionTreeNode.html";
import triggerTreeNodeTemplate from "./TriggerTreeNode.html";
import collectionTreeNodeContextMenuTemplate from "./CollectionTreeNodeContextMenu.html";
export class TreeNodeComponent {
constructor(data: any) {
return data.data;
}
}
export class ResourceTree {
constructor() {
return {
viewModel: TreeNodeComponent,
template: resourceTreeTemplate
};
}
}
export class DatabaseTreeNode {
constructor() {
return {
viewModel: TreeNodeComponent,
template: databaseTreeNoteTemplate
};
}
}
export class CollectionTreeNode {
constructor() {
return {
viewModel: TreeNodeComponent,
template: collectionTreeNodeTemplate
};
}
}
export class StoredProcedureTreeNode {
constructor() {
return {
viewModel: TreeNodeComponent,
template: storedProcedureTreeNodeTemplate
};
}
}
export class UserDefinedFunctionTreeNode {
constructor() {
return {
viewModel: TreeNodeComponent,
template: userDefinedFunctionTreeNodeTemplate
};
}
}
export class TriggerTreeNode {
constructor() {
return {
viewModel: TreeNodeComponent,
template: triggerTreeNodeTemplate
};
}
}
export class CollectionTreeNodeContextMenu {
constructor() {
return {
viewModel: TreeNodeComponent,
template: collectionTreeNodeContextMenuTemplate
};
}
}

View File

@@ -0,0 +1,180 @@
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import Collection from "./Collection";
import TriggerTab from "../Tabs/TriggerTab";
import ContextMenu from "../Menus/ContextMenu";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { ContextMenuButtonFactory } from "../ContextMenuButtonFactory";
export default class Trigger implements ViewModels.Trigger {
public nodeKind: string;
public container: ViewModels.Explorer;
public collection: ViewModels.Collection;
public self: string;
public rid: string;
public id: ko.Observable<string>;
public body: ko.Observable<string>;
public triggerType: ko.Observable<string>;
public triggerOperation: ko.Observable<string>;
public contextMenu: ViewModels.ContextMenu;
constructor(container: ViewModels.Explorer, collection: ViewModels.Collection, data: any) {
this.nodeKind = "Trigger";
this.container = container;
this.collection = collection;
this.self = data._self;
this.rid = data._rid;
this.id = ko.observable(data.id);
this.body = ko.observable(data.body);
this.triggerOperation = ko.observable(data.triggerOperation);
this.triggerType = ko.observable(data.triggerType);
this.contextMenu = new ContextMenu(this.container, this.rid);
this.contextMenu.options(ContextMenuButtonFactory.createTriggerContextMenuButton(container));
}
public select() {
this.container.selectedNode(this);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
description: "Trigger node",
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
}
public static create(source: ViewModels.Collection, event: MouseEvent) {
const id =
source.container
.openedTabs()
.filter((tab: ViewModels.Tab) => tab.tabKind === ViewModels.CollectionTabKind.Triggers).length + 1;
const trigger = <DataModels.Trigger>{
id: "",
body: "function trigger(){}",
triggerOperation: "All",
triggerType: "Pre"
};
let triggerTab: ViewModels.Tab = new TriggerTab({
resource: trigger,
isNew: true,
tabKind: ViewModels.CollectionTabKind.Triggers,
title: `New Trigger ${id}`,
tabPath: "",
documentClientUtility: source.container.documentClientUtility,
collection: source,
node: source,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(source.databaseId, source.id())}/trigger`,
selfLink: "",
isActive: ko.observable(false),
onUpdateTabsButtons: source.container.onUpdateTabsButtons,
openedTabs: source.container.openedTabs()
});
source.container.openedTabs.push(triggerTab);
// Activate
triggerTab.onTabClick();
// Hide Context Menu (if necessary)
source.contextMenu.hide(source, event);
}
public open = () => {
this.select();
let triggerTab: ViewModels.Tab = this.container
.openedTabs()
.filter(tab => tab.node && tab.node.rid === this.rid)[0];
if (!triggerTab) {
const triggerData = <DataModels.Trigger>{
_rid: this.rid,
_self: this.self,
id: this.id(),
body: this.body(),
triggerOperation: this.triggerOperation(),
triggerType: this.triggerType()
};
triggerTab = new TriggerTab({
resource: triggerData,
isNew: false,
tabKind: ViewModels.CollectionTabKind.Triggers,
title: triggerData.id,
tabPath: "",
documentClientUtility: this.container.documentClientUtility,
collection: this.collection,
node: this,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(
this.collection.databaseId,
this.collection.id()
)}/triggers/${this.id()}`,
selfLink: "",
isActive: ko.observable(false),
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
openedTabs: this.container.openedTabs()
});
this.container.openedTabs.push(triggerTab);
}
// Activate
triggerTab.onTabClick();
};
public delete(source: Collection, event: MouseEvent | KeyboardEvent) {
// Hide Context Menu (if necessary)
this.contextMenu.hide(source, event);
if (!window.confirm("Are you sure you want to delete the trigger?")) {
return;
}
const triggerData = <DataModels.Trigger>{
_rid: this.rid,
_self: this.self,
id: this.id(),
body: this.body(),
triggerOperation: this.triggerOperation(),
triggerType: this.triggerType()
};
this.container.documentClientUtility.deleteTrigger(this.collection, triggerData).then(
() => {
this.container.openedTabs.remove((tab: ViewModels.Tab) => tab.node && tab.node.rid === this.rid);
this.collection.children.remove(this);
},
reason => {}
);
}
public onKeyDown = (source: any, event: KeyboardEvent): boolean => {
if (event.key === "Delete") {
this.delete(source, event);
return false;
}
return true;
};
public onMenuKeyDown = (source: any, event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
this.contextMenu.hide(source, event);
return false;
}
return true;
};
public onKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.key === " " || event.key === "Enter") {
this.open();
return false;
}
return true;
};
}

View File

@@ -0,0 +1,62 @@
<div
role="treeitem"
tabindex="0"
class="pointerCursor"
data-bind="
click: $data.open,
event: {
keydown: onKeyDown,
keypress: onKeyPress,
contextmenu: $data.contextMenu.show
},
clickBubble: false,
contextmenuBubble: false,
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid,
contextmenushowing: $data.contextMenu.visible
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid
}"
>
<div class="triggersChildMenu treeChildMenu">
<div
class="childMenu"
data-bind="
attr: {
title: $data.id()
}"
>
<!--ko text: $data.id-->
<!--/ko-->
</div>
<span
class="menuEllipsis"
name="context menu"
role="button"
data-bind="
click: $data.contextMenu.show,
clickBubble: false
"
>&hellip;</span
>
</div>
</div>
<!-- Trigger Node Context Menu - Start -->
<div data-bind="event: { keydown: onMenuKeyDown }">
<div
class="context-menu-background"
data-bind="
visible: $data.contextMenu.visible,
click: $data.contextMenu.hide"
></div>
<div
class="context-menu"
data-bind="attr:{ tabindex: $data.contextMenu.tabIndex, id: $data.contextMenu.elementId }, visible: $data.contextMenu.visible, foreach: $data.contextMenu.options"
>
<command-button class="context-menu-option" params="{buttonProps: $data}"></command-button>
</div>
</div>

View File

@@ -0,0 +1,167 @@
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import UserDefinedFunctionTab from "../Tabs/UserDefinedFunctionTab";
import ContextMenu from "../Menus/ContextMenu";
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { ContextMenuButtonFactory } from "../ContextMenuButtonFactory";
import Collection from "./Collection";
export default class UserDefinedFunction implements ViewModels.UserDefinedFunction {
public nodeKind: string;
public container: ViewModels.Explorer;
public collection: ViewModels.Collection;
public self: string;
public rid: string;
public id: ko.Observable<string>;
public body: ko.Observable<string>;
public contextMenu: ViewModels.ContextMenu;
constructor(container: ViewModels.Explorer, collection: ViewModels.Collection, data: DataModels.UserDefinedFunction) {
this.nodeKind = "UserDefinedFunction";
this.container = container;
this.collection = collection;
this.self = data._self;
this.rid = data._rid;
this.id = ko.observable(data.id);
this.body = ko.observable(data.body);
this.contextMenu = new ContextMenu(this.container, this.rid);
this.contextMenu.options(ContextMenuButtonFactory.createUserDefinedFunctionContextMenuButton(container));
}
public static create(source: ViewModels.Collection, event: MouseEvent) {
const id =
source.container
.openedTabs()
.filter((tab: ViewModels.Tab) => tab.tabKind === ViewModels.CollectionTabKind.UserDefinedFunctions).length + 1;
const userDefinedFunction = <DataModels.UserDefinedFunction>{
id: "",
body: "function userDefinedFunction(){}"
};
let userDefinedFunctionTab: ViewModels.Tab = new UserDefinedFunctionTab({
resource: userDefinedFunction,
isNew: true,
tabKind: ViewModels.CollectionTabKind.UserDefinedFunctions,
title: `New UDF ${id}`,
tabPath: "",
documentClientUtility: source.container.documentClientUtility,
collection: source,
node: source,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(source.databaseId, source.id())}/udf`,
selfLink: "",
isActive: ko.observable(false),
onUpdateTabsButtons: source.container.onUpdateTabsButtons,
openedTabs: source.container.openedTabs()
});
source.container.openedTabs.push(userDefinedFunctionTab);
// Activate
userDefinedFunctionTab.onTabClick();
// Hide Context Menu (if necessary)
source.contextMenu.hide(source, event);
}
public open = () => {
this.select();
let userDefinedFunctionTab: ViewModels.Tab = this.container
.openedTabs()
.filter(tab => tab.node && tab.node.rid === this.rid)[0];
if (!userDefinedFunctionTab) {
const userDefinedFunctionData = <DataModels.UserDefinedFunction>{
_rid: this.rid,
_self: this.self,
id: this.id(),
body: this.body()
};
userDefinedFunctionTab = new UserDefinedFunctionTab({
resource: userDefinedFunctionData,
isNew: false,
tabKind: ViewModels.CollectionTabKind.UserDefinedFunctions,
title: userDefinedFunctionData.id,
tabPath: "",
documentClientUtility: this.container.documentClientUtility,
collection: this.collection,
node: this,
hashLocation: `${Constants.HashRoutePrefixes.collectionsWithIds(
this.collection.databaseId,
this.collection.id()
)}/udfs/${this.id()}`,
selfLink: "",
isActive: ko.observable(false),
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
openedTabs: this.container.openedTabs()
});
this.container.openedTabs.push(userDefinedFunctionTab);
}
// Activate
userDefinedFunctionTab.onTabClick();
};
public select() {
this.container.selectedNode(this);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
description: "UDF item node",
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree
});
}
public delete(source: Collection, event: MouseEvent | KeyboardEvent) {
// Hide Context Menu (if necessary)
this.contextMenu.hide(source, event);
if (!window.confirm("Are you sure you want to delete the user defined function?")) {
return;
}
const userDefinedFunctionData = <DataModels.UserDefinedFunction>{
_rid: this.rid,
_self: this.self,
id: this.id(),
body: this.body()
};
this.container.documentClientUtility.deleteUserDefinedFunction(this.collection, userDefinedFunctionData).then(
() => {
this.container.openedTabs.remove((tab: ViewModels.Tab) => tab.node && tab.node.rid === this.rid);
this.collection.children.remove(this);
},
reason => {}
);
}
public onKeyDown = (source: any, event: KeyboardEvent): boolean => {
if (event.key === "Delete") {
this.delete(source, event);
return false;
}
return true;
};
public onMenuKeyDown = (source: any, event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
this.contextMenu.hide(source, event);
return false;
}
return true;
};
public onKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.key === " " || event.key === "Enter") {
this.open();
return false;
}
return true;
};
}

View File

@@ -0,0 +1,62 @@
<div
role="treeitem"
tabindex="0"
class="pointerCursor"
data-bind="
click: $data.open,
event: {
keydown: onKeyDown,
keypress: onKeyPress,
contextmenu: $data.contextMenu.show
},
clickBubble: false,
contextmenuBubble: false,
css: {
highlight: true,
collectionNodeSelected: $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid,
contextmenushowing: $data.contextMenu.visible
},
attr:{
'aria-selected': $root.selectedNode && $root.selectedNode() && $root.selectedNode().rid === $data.rid
}"
>
<div class="userDefinedchildMenu treeChildMenu">
<div
class="childMenu"
data-bind="
attr: {
title: $data.id()
}"
>
<!--ko text: $data.id-->
<!--/ko-->
</div>
<span
class="menuEllipsis"
name="context menu"
role="button"
data-bind="
click: $data.contextMenu.show,
clickBubble: false
"
>&hellip;</span
>
</div>
</div>
<!-- User Defined Function Node Context Menu - Start -->
<div data-bind="event: { keydown: onMenuKeyDown }">
<div
class="context-menu-background"
data-bind="
visible: $data.contextMenu.visible,
click: $data.contextMenu.hide"
></div>
<div
class="context-menu"
data-bind="attr:{ tabindex: $data.contextMenu.tabIndex, id: $data.contextMenu.elementId }, visible: $data.contextMenu.visible, foreach: $data.contextMenu.options"
>
<command-button class="context-menu-option" params="{buttonProps: $data}"></command-button>
</div>
</div>
<!-- User Defined Function Node Context Menu - End -->

View File

@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Resource tree for resource token should render 1`] = `
<div
className="treeComponent dataResourceTree"
>
<TreeNodeComponent
generation={0}
node={
Object {
"children": Array [
Object {
"children": Array [
Object {
"isSelected": [Function],
"label": "Items",
"onClick": [Function],
},
],
"className": "collectionHeader",
"iconSrc": "",
"isExpanded": true,
"isSelected": [Function],
"label": "fakeId",
"onClick": [Function],
},
],
"isExpanded": true,
"label": undefined,
}
}
paddingLeft={0}
/>
</div>
`;