mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-19 17:01:13 +00:00
Initial Move from Azure DevOps to GitHub
This commit is contained in:
119
src/Explorer/Tree/AccessibleVerticalList.ts
Normal file
119
src/Explorer/Tree/AccessibleVerticalList.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
src/Explorer/Tree/Collection.test.ts
Normal file
114
src/Explorer/Tree/Collection.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
1657
src/Explorer/Tree/Collection.ts
Normal file
1657
src/Explorer/Tree/Collection.ts
Normal file
File diff suppressed because it is too large
Load Diff
425
src/Explorer/Tree/CollectionTreeNode.html
Normal file
425
src/Explorer/Tree/CollectionTreeNode.html
Normal 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"
|
||||
>…</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>
|
||||
16
src/Explorer/Tree/CollectionTreeNodeContextMenu.html
Normal file
16
src/Explorer/Tree/CollectionTreeNodeContextMenu.html
Normal 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>
|
||||
151
src/Explorer/Tree/ConflictId.ts
Normal file
151
src/Explorer/Tree/ConflictId.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
465
src/Explorer/Tree/Database.ts
Normal file
465
src/Explorer/Tree/Database.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
110
src/Explorer/Tree/DatabaseTreeNode.html
Normal file
110
src/Explorer/Tree/DatabaseTreeNode.html
Normal 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
|
||||
"
|
||||
>…</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>
|
||||
71
src/Explorer/Tree/DocumentId.ts
Normal file
71
src/Explorer/Tree/DocumentId.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/Explorer/Tree/ObjectId.ts
Normal file
14
src/Explorer/Tree/ObjectId.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
183
src/Explorer/Tree/ResourceTokenCollection.ts
Normal file
183
src/Explorer/Tree/ResourceTokenCollection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/Explorer/Tree/ResourceTree.html
Normal file
11
src/Explorer/Tree/ResourceTree.html
Normal 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>
|
||||
803
src/Explorer/Tree/ResourceTreeAdapter.tsx
Normal file
803
src/Explorer/Tree/ResourceTreeAdapter.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
116
src/Explorer/Tree/ResourceTreeAdapterForResourceToken.tsx
Normal file
116
src/Explorer/Tree/ResourceTreeAdapterForResourceToken.tsx
Normal 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()));
|
||||
}
|
||||
}
|
||||
239
src/Explorer/Tree/StoredProcedure.ts
Normal file
239
src/Explorer/Tree/StoredProcedure.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/Explorer/Tree/StoredProcedureTreeNode.html
Normal file
63
src/Explorer/Tree/StoredProcedureTreeNode.html
Normal 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
|
||||
"
|
||||
>…</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 -->
|
||||
76
src/Explorer/Tree/TreeComponents.ts
Normal file
76
src/Explorer/Tree/TreeComponents.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
180
src/Explorer/Tree/Trigger.ts
Normal file
180
src/Explorer/Tree/Trigger.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
62
src/Explorer/Tree/TriggerTreeNode.html
Normal file
62
src/Explorer/Tree/TriggerTreeNode.html
Normal 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
|
||||
"
|
||||
>…</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>
|
||||
167
src/Explorer/Tree/UserDefinedFunction.ts
Normal file
167
src/Explorer/Tree/UserDefinedFunction.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
62
src/Explorer/Tree/UserDefinedFunctionTreeNode.html
Normal file
62
src/Explorer/Tree/UserDefinedFunctionTreeNode.html
Normal 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
|
||||
"
|
||||
>…</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 -->
|
||||
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user