From dc56f7e154d056ee1666fe87016e086371ab1ee8 Mon Sep 17 00:00:00 2001 From: victor-meng <56978073+victor-meng@users.noreply.github.com> Date: Fri, 18 Sep 2020 16:00:21 -0700 Subject: [PATCH] Lazy load database offer in data explorer (#208) Co-authored-by: zfoster --- src/Common/dataAccess/readDatabaseOffer.ts | 83 ++++++++++ src/Common/dataAccess/readOffers.ts | 36 +++++ src/Contracts/DataModels.ts | 7 + src/Contracts/ViewModels.ts | 6 +- src/Explorer/ContextMenuButtonFactory.ts | 6 +- .../TreeComponent/TreeComponent.test.tsx | 16 ++ .../Controls/TreeComponent/TreeComponent.tsx | 15 +- .../__snapshots__/TreeComponent.test.tsx.snap | 108 +++++++++++++ .../Controls/TreeComponent/treeComponent.less | 11 +- src/Explorer/Explorer.ts | 137 +++++++--------- .../CommandBarComponentButtonFactory.ts | 25 --- src/Explorer/Panes/AddCollectionPane.ts | 5 +- .../Panes/CassandraAddCollectionPane.ts | 2 - .../Panes/DeleteDatabaseConfirmationPane.ts | 3 +- src/Explorer/Tabs/DatabaseSettingsTab.ts | 4 +- src/Explorer/Tabs/SettingsTab.test.ts | 2 - src/Explorer/Tabs/SettingsTab.ts | 4 +- src/Explorer/Tree/Database.ts | 148 ++++-------------- src/Explorer/Tree/ResourceTreeAdapter.tsx | 19 ++- test/container.spec.ts | 22 +-- 20 files changed, 399 insertions(+), 260 deletions(-) create mode 100644 src/Common/dataAccess/readDatabaseOffer.ts create mode 100644 src/Common/dataAccess/readOffers.ts diff --git a/src/Common/dataAccess/readDatabaseOffer.ts b/src/Common/dataAccess/readDatabaseOffer.ts new file mode 100644 index 000000000..e3b27d2b4 --- /dev/null +++ b/src/Common/dataAccess/readDatabaseOffer.ts @@ -0,0 +1,83 @@ +import * as DataModels from "../../Contracts/DataModels"; +import { AuthType } from "../../AuthType"; +import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType"; +import { HttpHeaders } from "../Constants"; +import { RequestOptions } from "@azure/cosmos/dist-esm"; +import { client } from "../CosmosClient"; +import { getSqlDatabaseThroughput } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources"; +import { getMongoDBDatabaseThroughput } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources"; +import { getCassandraKeyspaceThroughput } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources"; +import { getGremlinDatabaseThroughput } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources"; +import { readOffers } from "./readOffers"; +import { userContext } from "../../UserContext"; + +export const readDatabaseOffer = async ( + params: DataModels.ReadDatabaseOfferParams +): Promise => { + let offerId = params.offerId; + if (!offerId) { + if (window.authType === AuthType.AAD && !userContext.useSDKOperations) { + try { + offerId = await getDatabaseOfferIdWithARM(params.databaseId); + } catch (error) { + if (error.code !== "NotFound") { + throw new Error(error); + } + return undefined; + } + } else { + offerId = await getDatabaseOfferIdWithSDK(params.databaseResourceId, params.isServerless); + if (!offerId) { + return undefined; + } + } + } + + const options: RequestOptions = { + initialHeaders: { + [HttpHeaders.populateCollectionThroughputInfo]: true + } + }; + + const response = await client() + .offer(offerId) + .read(options); + return ( + response && { + ...response.resource, + headers: response.headers + } + ); +}; + +const getDatabaseOfferIdWithARM = async (databaseId: string): Promise => { + let rpResponse; + const subscriptionId = userContext.subscriptionId; + const resourceGroup = userContext.resourceGroup; + const accountName = userContext.databaseAccount.name; + const defaultExperience = userContext.defaultExperience; + switch (defaultExperience) { + case DefaultAccountExperienceType.DocumentDB: + rpResponse = await getSqlDatabaseThroughput(subscriptionId, resourceGroup, accountName, databaseId); + break; + case DefaultAccountExperienceType.MongoDB: + rpResponse = await getMongoDBDatabaseThroughput(subscriptionId, resourceGroup, accountName, databaseId); + break; + case DefaultAccountExperienceType.Cassandra: + rpResponse = await getCassandraKeyspaceThroughput(subscriptionId, resourceGroup, accountName, databaseId); + break; + case DefaultAccountExperienceType.Graph: + rpResponse = await getGremlinDatabaseThroughput(subscriptionId, resourceGroup, accountName, databaseId); + break; + default: + throw new Error(`Unsupported default experience type: ${defaultExperience}`); + } + + return rpResponse?.name; +}; + +const getDatabaseOfferIdWithSDK = async (databaseResourceId: string, isServerless: boolean): Promise => { + const offers = await readOffers(isServerless); + const offer = offers.find(offer => offer.resource === databaseResourceId); + return offer?.id; +}; diff --git a/src/Common/dataAccess/readOffers.ts b/src/Common/dataAccess/readOffers.ts new file mode 100644 index 000000000..460a591f9 --- /dev/null +++ b/src/Common/dataAccess/readOffers.ts @@ -0,0 +1,36 @@ +import { Offer } from "../../Contracts/DataModels"; +import { ClientDefaults } from "../Constants"; +import { MessageTypes } from "../../Contracts/ExplorerContracts"; +import { Platform, configContext } from "../../ConfigContext"; +import { client } from "../CosmosClient"; +import { sendCachedDataMessage } from "../MessageHandler"; +import { userContext } from "../../UserContext"; + +export const readOffers = async (isServerless?: boolean): Promise => { + if (isServerless) { + return []; // Reading offers is not supported for serverless accounts + } + + try { + if (configContext.platform === Platform.Portal) { + return sendCachedDataMessage(MessageTypes.AllOffers, [ + userContext.databaseAccount.id, + ClientDefaults.portalCacheTimeoutMs + ]); + } + } catch (error) { + // If error getting cached Offers, continue on and read via SDK + } + + return client() + .offers.readAll() + .fetchAll() + .then(response => response.resources) + .catch(error => { + // This should be removed when we can correctly identify if an account is serverless when connected using connection string too. + if (error.message.includes("Reading or replacing offers is not supported for serverless accounts")) { + return []; + } + throw error; + }); +}; diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index ea141bbee..a5dbc03b0 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -289,6 +289,13 @@ export interface CreateCollectionParams { uniqueKeyPolicy?: UniqueKeyPolicy; } +export interface ReadDatabaseOfferParams { + databaseId: string; + databaseResourceId?: string; + isServerless?: boolean; + offerId?: string; +} + export interface Notification { id: string; kind: string; diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index ab98e913c..fd3b10fa8 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -81,15 +81,15 @@ export interface Database extends TreeNode { selectedSubnodeKind: ko.Observable; selectDatabase(): void; - expandDatabase(): void; + expandDatabase(): Promise; collapseDatabase(): void; - loadCollections(): Q.Promise; + loadCollections(): Promise; findCollectionWithId(collectionRid: string): Collection; openAddCollection(database: Database, event: MouseEvent): void; onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void; - readSettings(): void; onSettingsClick: () => void; + loadOffer(): Promise; } export interface CollectionBase extends TreeNode { diff --git a/src/Explorer/ContextMenuButtonFactory.ts b/src/Explorer/ContextMenuButtonFactory.ts index aeb8f177f..eea8253ce 100644 --- a/src/Explorer/ContextMenuButtonFactory.ts +++ b/src/Explorer/ContextMenuButtonFactory.ts @@ -42,7 +42,8 @@ export class ResourceTreeContextMenuButtonFactory { const deleteDatabaseMenuItem = { iconSrc: DeleteDatabaseIcon, onClick: () => container.deleteDatabaseConfirmationPane.open(), - label: container.deleteDatabaseText() + label: container.deleteDatabaseText(), + styleClass: "deleteDatabaseMenuItem" }; return [newCollectionMenuItem, deleteDatabaseMenuItem]; } @@ -112,7 +113,8 @@ export class ResourceTreeContextMenuButtonFactory { const selectedCollection: ViewModels.Collection = container.findSelectedCollection(); selectedCollection && selectedCollection.onDeleteCollectionContextMenuClick(selectedCollection, null); }, - label: container.deleteCollectionText() + label: container.deleteCollectionText(), + styleClass: "deleteCollectionMenuItem" }); return items; diff --git a/src/Explorer/Controls/TreeComponent/TreeComponent.test.tsx b/src/Explorer/Controls/TreeComponent/TreeComponent.test.tsx index 6cbbc2354..875d6406d 100644 --- a/src/Explorer/Controls/TreeComponent/TreeComponent.test.tsx +++ b/src/Explorer/Controls/TreeComponent/TreeComponent.test.tsx @@ -159,4 +159,20 @@ describe("TreeNodeComponent", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + + it("renders loading icon", () => { + const node: TreeNode = { + label: "label", + children: [], + isExpanded: true + }; + + const props = { + node, + generation: 2, + paddingLeft: 9 + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); }); diff --git a/src/Explorer/Controls/TreeComponent/TreeComponent.tsx b/src/Explorer/Controls/TreeComponent/TreeComponent.tsx index 703dbc34f..520021d20 100644 --- a/src/Explorer/Controls/TreeComponent/TreeComponent.tsx +++ b/src/Explorer/Controls/TreeComponent/TreeComponent.tsx @@ -17,12 +17,14 @@ import { import TriangleDownIcon from "../../../../images/Triangle-down.svg"; import TriangleRightIcon from "../../../../images/Triangle-right.svg"; +import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif"; export interface TreeNodeMenuItem { label: string; onClick: () => void; iconSrc?: string; isDisabled?: boolean; + styleClass?: string; } export interface TreeNode { @@ -37,6 +39,7 @@ export interface TreeNode { data?: any; // Piece of data corresponding to this node timestamp?: number; isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves + isLoading?: boolean; isSelected?: () => boolean; onClick?: (isExpanded: boolean) => void; // Only if a leaf, other click will expand/collapse onExpanded?: () => void; @@ -183,6 +186,9 @@ export class TreeNodeComponent extends React.Component +
+ +
{node.children && (
@@ -256,13 +262,20 @@ export class TreeNodeComponent extends React.Component e.target.dispatchEvent(TreeNodeComponent.createClickEvent())} > {props.item.onRenderIcon()} - {props.item.text} + + {props.item.text} +
), items: node.contextMenu.map((menuItem: TreeNodeMenuItem) => ({ key: menuItem.label, text: menuItem.label, disabled: menuItem.isDisabled, + className: menuItem.styleClass, onClick: menuItem.onClick, onRenderIcon: (props: any) => })) diff --git a/src/Explorer/Controls/TreeComponent/__snapshots__/TreeComponent.test.tsx.snap b/src/Explorer/Controls/TreeComponent/__snapshots__/TreeComponent.test.tsx.snap index 037a76d2c..a46f92cb3 100644 --- a/src/Explorer/Controls/TreeComponent/__snapshots__/TreeComponent.test.tsx.snap +++ b/src/Explorer/Controls/TreeComponent/__snapshots__/TreeComponent.test.tsx.snap @@ -63,6 +63,15 @@ exports[`TreeNodeComponent does not render children by default 1`] = ` label +
+ +
+
+ +
`; +exports[`TreeNodeComponent renders loading icon 1`] = ` +
+
+ label branch is expanded + + label + +
+
+ +
+ +
+ +
+`; + exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = `
+
+ +
+
+ +
= Q.defer(); - - const refreshDatabases = (offers?: DataModels.Offer[]) => { - this._setLoadingStatusText("Fetching databases..."); - readDatabases().then( - (databases: DataModels.Database[]) => { - this._setLoadingStatusText("Successfully fetched databases."); - TelemetryProcessor.traceSuccess( - Action.LoadDatabases, - { - databaseAccountName: this.databaseAccount().name, - defaultExperience: this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree + this._setLoadingStatusText("Fetching databases..."); + readDatabases().then( + (databases: DataModels.Database[]) => { + this._setLoadingStatusText("Successfully fetched databases."); + TelemetryProcessor.traceSuccess( + Action.LoadDatabases, + { + databaseAccountName: this.databaseAccount().name, + defaultExperience: this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree + }, + startKey + ); + const currentlySelectedNode: ViewModels.TreeNode = this.selectedNode(); + const deltaDatabases = this.getDeltaDatabases(databases); + this.addDatabasesToList(deltaDatabases.toAdd); + this.deleteDatabasesFromList(deltaDatabases.toDelete); + this.selectedNode(currentlySelectedNode); + this._setLoadingStatusText("Fetching containers..."); + this.refreshAndExpandNewDatabases(deltaDatabases.toAdd) + .then( + () => { + this._setLoadingStatusText("Successfully fetched containers."); + deferred.resolve(); }, - startKey - ); - const currentlySelectedNode: ViewModels.TreeNode = this.selectedNode(); - const deltaDatabases = this.getDeltaDatabases(databases, offers); - this.addDatabasesToList(deltaDatabases.toAdd); - this.deleteDatabasesFromList(deltaDatabases.toDelete); - this.selectedNode(currentlySelectedNode); - this._setLoadingStatusText("Fetching containers..."); - this.refreshAndExpandNewDatabases(deltaDatabases.toAdd) - .then( - () => { - this._setLoadingStatusText("Successfully fetched containers."); - deferred.resolve(); - }, - reason => { - this._setLoadingStatusText("Failed to fetch containers."); - deferred.reject(reason); - } - ) - .finally(() => this.isRefreshingExplorer(false)); - }, - error => { - this._setLoadingStatusText("Failed to fetch databases."); - this.isRefreshingExplorer(false); - deferred.reject(error); - TelemetryProcessor.traceFailure( - Action.LoadDatabases, - { - databaseAccountName: this.databaseAccount().name, - defaultExperience: this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree, - error: JSON.stringify(error) - }, - startKey - ); - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Error while refreshing databases: ${JSON.stringify(error)}` - ); - } - ); - }; - - const offerPromise: Q.Promise = readOffers({ isServerless: this.isServerlessEnabled() }); - this._setLoadingStatusText("Fetching offers..."); - offerPromise.then( - (offers: DataModels.Offer[]) => { - this._setLoadingStatusText("Successfully fetched offers."); - refreshDatabases(offers); + reason => { + this._setLoadingStatusText("Failed to fetch containers."); + deferred.reject(reason); + } + ) + .finally(() => this.isRefreshingExplorer(false)); }, error => { - this._setLoadingStatusText("Failed to fetch offers."); + this._setLoadingStatusText("Failed to fetch databases."); this.isRefreshingExplorer(false); deferred.reject(error); TelemetryProcessor.traceFailure( @@ -2103,16 +2072,13 @@ export default class Explorer { defaultExperience: this.defaultExperience && this.defaultExperience(), dataExplorerArea: Constants.Areas.ResourceTree }); - databasesToLoad.forEach((database: ViewModels.Database) => { - loadCollectionPromises.push( - database.loadCollections().finally(() => { - const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.rid === database.rid); - if (isNewDatabase) { - database.expandDatabase(); - } - this.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().rid === database.rid); - }) - ); + databasesToLoad.forEach(async (database: ViewModels.Database) => { + await database.loadCollections(); + const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.rid === database.rid); + if (isNewDatabase) { + database.expandDatabase(); + } + this.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().rid === database.rid); }); Q.all(loadCollectionPromises).done( @@ -2257,8 +2223,7 @@ export default class Explorer { } private getDeltaDatabases( - updatedDatabaseList: DataModels.Database[], - updatedOffersList: DataModels.Offer[] + updatedDatabaseList: DataModels.Database[] ): { toAdd: ViewModels.Database[]; toDelete: ViewModels.Database[] } { const newDatabases: DataModels.Database[] = _.filter(updatedDatabaseList, (database: DataModels.Database) => { const databaseExists = _.some( @@ -2267,10 +2232,9 @@ export default class Explorer { ); return !databaseExists; }); - const databasesToAdd: ViewModels.Database[] = _.map(newDatabases, (newDatabase: DataModels.Database) => { - const databaseOffer: DataModels.Offer = this.getOfferForResource(updatedOffersList, newDatabase._self); - return new Database(this, newDatabase, databaseOffer); - }); + const databasesToAdd: ViewModels.Database[] = newDatabases.map( + (newDatabase: DataModels.Database) => new Database(this, newDatabase) + ); let databasesToDelete: ViewModels.Database[] = []; ko.utils.arrayForEach(this.databases(), (database: ViewModels.Database) => { @@ -2320,10 +2284,6 @@ export default class Explorer { return null; } - private getOfferForResource(offers: DataModels.Offer[], resourceId: string): DataModels.Offer { - return _.find(offers, (offer: DataModels.Offer) => offer.resource === resourceId); - } - public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to upload notebook, but notebook is not enabled"; @@ -3160,4 +3120,15 @@ export default class Explorer { } } } + + public async loadSelectedDatabaseOffer(): Promise { + const database = this.findSelectedDatabase(); + await database?.loadOffer(); + } + + public async loadDatabaseOffers(): Promise { + this.databases()?.forEach(async (database: ViewModels.Database) => { + await database.loadOffer(); + }); + } } diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts index f82a2937a..1aaa46170 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts @@ -391,31 +391,6 @@ export class CommandBarComponentButtonFactory { return buttons; } - private static createScaleAndSettingsButton(container: Explorer): CommandButtonComponentProps { - let isShared = false; - if (container.isDatabaseNodeSelected()) { - isShared = container.findSelectedDatabase().isDatabaseShared(); - } else if (container.isNodeKindSelected("Collection")) { - const database: ViewModels.Database = container.findSelectedCollection().getDatabase(); - isShared = database && database.isDatabaseShared(); - } - - const label = isShared ? "Settings" : "Scale & Settings"; - - return { - iconSrc: ScaleIcon, - iconAlt: label, - onCommandClick: () => { - const selectedCollection: ViewModels.Collection = container.findSelectedCollection(); - selectedCollection && (selectedCollection).onSettingsClick(); - }, - commandButtonLabel: label, - ariaLabel: label, - hasPopup: true, - disabled: container.isDatabaseNodeOrNoneSelected() - }; - } - private static createNewNotebookButton(container: Explorer): CommandButtonComponentProps { const label = "New Notebook"; return { diff --git a/src/Explorer/Panes/AddCollectionPane.ts b/src/Explorer/Panes/AddCollectionPane.ts index bf24c05c8..ef17941e8 100644 --- a/src/Explorer/Panes/AddCollectionPane.ts +++ b/src/Explorer/Panes/AddCollectionPane.ts @@ -681,7 +681,7 @@ export default class AddCollectionPane extends ContextualPaneBase { return true; }; - public open(databaseId?: string) { + public async open(databaseId?: string) { super.open(); // TODO: Figure out if a database level partition split is about to happen once shared throughput read is available this.formWarnings(""); @@ -715,6 +715,7 @@ export default class AddCollectionPane extends ContextualPaneBase { dataExplorerArea: Constants.Areas.ContextualPane }; + await this.container.loadDatabaseOffers(); this._onDatabasesChange(this.container.databases()); this._setFocus(); @@ -748,8 +749,6 @@ export default class AddCollectionPane extends ContextualPaneBase { const cachedDatabaseIdsList = _.map(newDatabaseIds, (database: ViewModels.Database) => { if (database && database.offer && database.offer()) { this._databaseOffers.set(database.id(), database.offer()); - } else if (database && database.isDatabaseShared && database.isDatabaseShared()) { - database.readSettings(); } return database.id(); diff --git a/src/Explorer/Panes/CassandraAddCollectionPane.ts b/src/Explorer/Panes/CassandraAddCollectionPane.ts index 6198fa3f8..3e0142295 100644 --- a/src/Explorer/Panes/CassandraAddCollectionPane.ts +++ b/src/Explorer/Panes/CassandraAddCollectionPane.ts @@ -268,8 +268,6 @@ export default class CassandraAddCollectionPane extends ContextualPaneBase { const cachedKeyspaceIdsList = _.map(newKeyspaceIds, (keyspace: ViewModels.Database) => { if (keyspace && keyspace.offer && !!keyspace.offer()) { this.keyspaceOffers.set(keyspace.id(), keyspace.offer()); - } else if (keyspace && keyspace.isDatabaseShared && keyspace.isDatabaseShared()) { - keyspace.readSettings(); } return keyspace.id(); }); diff --git a/src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts b/src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts index ff1ae42c9..b0c32d973 100644 --- a/src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts +++ b/src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts @@ -132,7 +132,8 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase { super.resetData(); } - public open() { + public async open() { + await this.container.loadSelectedDatabaseOffer(); this.recordDeleteFeedback(this.shouldRecordFeedback()); super.open(); } diff --git a/src/Explorer/Tabs/DatabaseSettingsTab.ts b/src/Explorer/Tabs/DatabaseSettingsTab.ts index 22bf74b30..558da21ce 100644 --- a/src/Explorer/Tabs/DatabaseSettingsTab.ts +++ b/src/Explorer/Tabs/DatabaseSettingsTab.ts @@ -598,7 +598,6 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels. () => { this.container.isRefreshingExplorer(false); this._setBaseline(); - this.database.readSettings(); TelemetryProcessor.traceSuccess( Action.UpdateSettings, { @@ -643,8 +642,9 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels. }; public onActivate(): Q.Promise { - return super.onActivate().then(() => { + return super.onActivate().then(async () => { this.database.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings); + await this.database.loadOffer(); }); } diff --git a/src/Explorer/Tabs/SettingsTab.test.ts b/src/Explorer/Tabs/SettingsTab.test.ts index 3e3e13b00..906941431 100644 --- a/src/Explorer/Tabs/SettingsTab.test.ts +++ b/src/Explorer/Tabs/SettingsTab.test.ts @@ -346,7 +346,6 @@ describe("Settings tab", () => { const offer: DataModels.Offer = null; const defaultTtl = 200; - const database = new Database(explorer, baseDatabase, null); const conflictResolutionPolicy = { mode: DataModels.ConflictResolutionMode.LastWriterWins, conflictResolutionPath: "/_ts" @@ -507,7 +506,6 @@ describe("Settings tab", () => { } } }; - const database = new Database(explorer, baseDatabase, null); const container: DataModels.Collection = { _rid: "_rid", _self: "", diff --git a/src/Explorer/Tabs/SettingsTab.ts b/src/Explorer/Tabs/SettingsTab.ts index 315a176fc..7abeda7df 100644 --- a/src/Explorer/Tabs/SettingsTab.ts +++ b/src/Explorer/Tabs/SettingsTab.ts @@ -1270,8 +1270,10 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor } public onActivate(): Q.Promise { - return super.onActivate().then(() => { + return super.onActivate().then(async () => { this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings); + const database: ViewModels.Database = this.collection.getDatabase(); + await database.loadOffer(); }); } diff --git a/src/Explorer/Tree/Database.ts b/src/Explorer/Tree/Database.ts index a2dce06cf..2a8fa9360 100644 --- a/src/Explorer/Tree/Database.ts +++ b/src/Explorer/Tree/Database.ts @@ -12,8 +12,8 @@ import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils" import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import * as Logger from "../../Common/Logger"; import Explorer from "../Explorer"; -import { readOffers, readOffer } from "../../Common/DocumentClientUtilityBase"; import { readCollections } from "../../Common/dataAccess/readCollections"; +import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer"; export default class Database implements ViewModels.Database { public nodeKind: string; @@ -27,13 +27,13 @@ export default class Database implements ViewModels.Database { public isDatabaseShared: ko.Computed; public selectedSubnodeKind: ko.Observable; - constructor(container: Explorer, data: any, offer: DataModels.Offer) { + constructor(container: Explorer, data: any) { 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.offer = ko.observable(); this.collections = ko.observableArray(); this.isDatabaseExpanded = ko.observable(false); this.selectedSubnodeKind = ko.observable(); @@ -66,7 +66,7 @@ export default class Database implements ViewModels.Database { dataExplorerArea: Constants.Areas.Tab, tabTitle: "Scale" }); - Q.all([pendingNotificationsPromise, this.readSettings()]).then( + pendingNotificationsPromise.then( (data: any) => { const pendingNotification: DataModels.Notification = data && data[0]; settingsTab = new DatabaseSettingsTab({ @@ -121,80 +121,6 @@ export default class Database implements ViewModels.Database { } }; - public readSettings(): Q.Promise { - const deferred: Q.Deferred = Q.defer(); - this.container.isRefreshingExplorer(true); - const databaseDataModel: 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 = readOffers({ - isServerless: this.container.isServerlessEnabled() - }); - Q.all([offerInfoPromise]).then( - () => { - this.container.isRefreshingExplorer(false); - - const databaseOffer: DataModels.Offer = this._getOfferForDatabase( - offerInfoPromise.valueOf(), - databaseDataModel - ); - - if (!databaseOffer) { - return; - } - - 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() && @@ -219,23 +145,13 @@ export default class Database implements ViewModels.Database { }); } - public expandCollapseDatabase() { - this.selectDatabase(); - if (this.isDatabaseExpanded()) { - this.collapseDatabase(); - } else { - this.expandDatabase(); - } - this.container.onUpdateTabsButtons([]); - this.container.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().rid === this.rid); - } - - public expandDatabase() { + public async expandDatabase() { if (this.isDatabaseExpanded()) { return; } - this.loadCollections(); + await this.loadOffer(); + await this.loadCollections(); this.isDatabaseExpanded(true); TelemetryProcessor.trace(Action.ExpandTreeNode, ActionModifiers.Mark, { description: "Database node", @@ -259,32 +175,19 @@ export default class Database implements ViewModels.Database { }); } - public loadCollections(): Q.Promise { - let collectionVMs: Collection[] = []; - let deferred: Q.Deferred = Q.defer(); + public async loadCollections(): Promise { + const collectionVMs: Collection[] = []; + const collections: DataModels.Collection[] = await readCollections(this.id()); + const deltaCollections = this.getDeltaCollections(collections); - readCollections(this.id()).then( - (collections: DataModels.Collection[]) => { - let collectionsToAddVMPromises: Q.Promise[] = []; - 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); + }); - 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; + //merge collections + this.addCollectionsToList(collectionVMs); + this.deleteCollectionsFromList(deltaCollections.toDelete); } public openAddCollection(database: Database, event: MouseEvent) { @@ -296,6 +199,17 @@ export default class Database implements ViewModels.Database { return _.find(this.collections(), (collection: ViewModels.Collection) => collection.id() === collectionId); } + public async loadOffer(): Promise { + if (!this.offer()) { + const params: DataModels.ReadDatabaseOfferParams = { + databaseId: this.id(), + databaseResourceId: this.self, + isServerless: this.container.isServerlessEnabled() + }; + this.offer(await readDatabaseOffer(params)); + } + } + private _getPendingThroughputSplitNotification(): Q.Promise { if (!this.container) { return Q.resolve(undefined); @@ -387,8 +301,4 @@ export default class Database implements ViewModels.Database { this.collections(collectionsToKeep); } - - private _getOfferForDatabase(offers: DataModels.Offer[], database: DataModels.Database): DataModels.Offer { - return _.find(offers, (offer: DataModels.Offer) => offer.resource === database._self); - } } diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index f47c289a1..f8c2d5c55 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -170,14 +170,17 @@ export class ResourceTreeAdapter implements ReactAdapter { children: [], isSelected: () => this.isDataNodeSelected(database.rid, "Database", undefined), contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(this.container, database), - onClick: isExpanded => { + onClick: async isExpanded => { // Rewritten version of expandCollapseDatabase(): - if (!isExpanded) { - database.expandDatabase(); - database.loadCollections(); - } else { + if (isExpanded) { database.collapseDatabase(); + } else { + if (databaseNode.children?.length === 0) { + databaseNode.isLoading = true; + } + await database.expandDatabase(); } + databaseNode.isLoading = false; database.selectDatabase(); this.container.onUpdateTabsButtons([]); this.container.tabsManager.refreshActiveTab( @@ -203,6 +206,12 @@ export class ResourceTreeAdapter implements ReactAdapter { databaseNode.children.push(this.buildCollectionNode(database, collection)) ); + database.collections.subscribe((collections: ViewModels.Collection[]) => { + collections.forEach((collection: ViewModels.Collection) => + databaseNode.children.push(this.buildCollectionNode(database, collection)) + ); + }); + return databaseNode; }); diff --git a/test/container.spec.ts b/test/container.spec.ts index 2bb0c65ea..04fb66836 100644 --- a/test/container.spec.ts +++ b/test/container.spec.ts @@ -56,24 +56,27 @@ describe('Collection Add and Delete SQL spec', () => { await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true }); await frame.click(`div[data-test="${dbId}"]`); - await frame.waitFor(`span[title="${collectionId}"]`); + await frame.waitFor(`span[title="${collectionId}"]`, { visible: true }); + await frame.waitFor(3000) + await frame.waitFor(`span[title="${collectionId}"]`, { visible: true }); // delete container // click context menu for container - await frame.waitFor(`div[data-test="${collectionId}"] > div > button`); + await frame.waitFor(`div[data-test="${collectionId}"] > div > button`, { visible: true }); + await frame.waitFor(`span[title="${collectionId}"]`, { visible: true }); await frame.click(`div[data-test="${collectionId}"] > div > button`); - + await frame.waitFor(2000) // click delete container - await frame.waitForSelector('body > div.ms-Layer.ms-Layer--fixed'); - await frame.waitFor(1000); - const elements = await frame.$$('span[class="treeComponentMenuItemLabel"]') - await elements[4].click(); + await frame.waitFor('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]', { visible: true }); + await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]'); // confirm delete container + await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true }) await frame.type('input[data-test="confirmCollectionId"]', collectionId.trim()); // click delete + await frame.waitFor('input[data-test="deleteCollection"]', { visible: true }) await frame.click('input[data-test="deleteCollection"]'); await frame.waitFor(5000); await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true }); @@ -87,9 +90,8 @@ describe('Collection Add and Delete SQL spec', () => { await button.asElement().click(); // click delete database - await frame.waitFor(1000); - const dbElements = await frame.$$('span[class="treeComponentMenuItemLabel"]') - await dbElements[1].click(); + await frame.waitFor('span[class="treeComponentMenuItemLabel deleteDatabaseMenuItemLabel"]'); + await frame.click('span[class="treeComponentMenuItemLabel deleteDatabaseMenuItemLabel"]'); // confirm delete database await frame.type('input[data-test="confirmDatabaseId"]', dbId.trim());