Lazy load database offer in data explorer (#208)

Co-authored-by: zfoster <notzachfoster@gmail.com>
This commit is contained in:
victor-meng 2020-09-18 16:00:21 -07:00 committed by GitHub
parent e62184a1f2
commit dc56f7e154
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 399 additions and 260 deletions

View File

@ -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<DataModels.OfferWithHeaders> => {
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<string> => {
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<string> => {
const offers = await readOffers(isServerless);
const offer = offers.find(offer => offer.resource === databaseResourceId);
return offer?.id;
};

View File

@ -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<Offer[]> => {
if (isServerless) {
return []; // Reading offers is not supported for serverless accounts
}
try {
if (configContext.platform === Platform.Portal) {
return sendCachedDataMessage<Offer[]>(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;
});
};

View File

@ -289,6 +289,13 @@ export interface CreateCollectionParams {
uniqueKeyPolicy?: UniqueKeyPolicy; uniqueKeyPolicy?: UniqueKeyPolicy;
} }
export interface ReadDatabaseOfferParams {
databaseId: string;
databaseResourceId?: string;
isServerless?: boolean;
offerId?: string;
}
export interface Notification { export interface Notification {
id: string; id: string;
kind: string; kind: string;

View File

@ -81,15 +81,15 @@ export interface Database extends TreeNode {
selectedSubnodeKind: ko.Observable<CollectionTabKind>; selectedSubnodeKind: ko.Observable<CollectionTabKind>;
selectDatabase(): void; selectDatabase(): void;
expandDatabase(): void; expandDatabase(): Promise<void>;
collapseDatabase(): void; collapseDatabase(): void;
loadCollections(): Q.Promise<void>; loadCollections(): Promise<void>;
findCollectionWithId(collectionRid: string): Collection; findCollectionWithId(collectionRid: string): Collection;
openAddCollection(database: Database, event: MouseEvent): void; openAddCollection(database: Database, event: MouseEvent): void;
onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void; onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void;
readSettings(): void;
onSettingsClick: () => void; onSettingsClick: () => void;
loadOffer(): Promise<void>;
} }
export interface CollectionBase extends TreeNode { export interface CollectionBase extends TreeNode {

View File

@ -42,7 +42,8 @@ export class ResourceTreeContextMenuButtonFactory {
const deleteDatabaseMenuItem = { const deleteDatabaseMenuItem = {
iconSrc: DeleteDatabaseIcon, iconSrc: DeleteDatabaseIcon,
onClick: () => container.deleteDatabaseConfirmationPane.open(), onClick: () => container.deleteDatabaseConfirmationPane.open(),
label: container.deleteDatabaseText() label: container.deleteDatabaseText(),
styleClass: "deleteDatabaseMenuItem"
}; };
return [newCollectionMenuItem, deleteDatabaseMenuItem]; return [newCollectionMenuItem, deleteDatabaseMenuItem];
} }
@ -112,7 +113,8 @@ export class ResourceTreeContextMenuButtonFactory {
const selectedCollection: ViewModels.Collection = container.findSelectedCollection(); const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onDeleteCollectionContextMenuClick(selectedCollection, null); selectedCollection && selectedCollection.onDeleteCollectionContextMenuClick(selectedCollection, null);
}, },
label: container.deleteCollectionText() label: container.deleteCollectionText(),
styleClass: "deleteCollectionMenuItem"
}); });
return items; return items;

View File

@ -159,4 +159,20 @@ describe("TreeNodeComponent", () => {
const wrapper = shallow(<TreeNodeComponent {...props} />); const wrapper = shallow(<TreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot(); 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(<TreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
}); });

View File

@ -17,12 +17,14 @@ import {
import TriangleDownIcon from "../../../../images/Triangle-down.svg"; import TriangleDownIcon from "../../../../images/Triangle-down.svg";
import TriangleRightIcon from "../../../../images/Triangle-right.svg"; import TriangleRightIcon from "../../../../images/Triangle-right.svg";
import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif";
export interface TreeNodeMenuItem { export interface TreeNodeMenuItem {
label: string; label: string;
onClick: () => void; onClick: () => void;
iconSrc?: string; iconSrc?: string;
isDisabled?: boolean; isDisabled?: boolean;
styleClass?: string;
} }
export interface TreeNode { export interface TreeNode {
@ -37,6 +39,7 @@ export interface TreeNode {
data?: any; // Piece of data corresponding to this node data?: any; // Piece of data corresponding to this node
timestamp?: number; timestamp?: number;
isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves
isLoading?: boolean;
isSelected?: () => boolean; isSelected?: () => boolean;
onClick?: (isExpanded: boolean) => void; // Only if a leaf, other click will expand/collapse onClick?: (isExpanded: boolean) => void; // Only if a leaf, other click will expand/collapse
onExpanded?: () => void; onExpanded?: () => void;
@ -183,6 +186,9 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
)} )}
{node.contextMenu && this.renderContextMenuButton(node)} {node.contextMenu && this.renderContextMenuButton(node)}
</div> </div>
<div className="loadingIconContainer">
<img className="loadingIcon" src={LoadingIndicator_3Squares} hidden={!this.props.node.isLoading} />
</div>
{node.children && ( {node.children && (
<AnimateHeight duration={TreeNodeComponent.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0}> <AnimateHeight duration={TreeNodeComponent.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0}>
<div className="nodeChildren" data-test={node.label}> <div className="nodeChildren" data-test={node.label}>
@ -256,13 +262,20 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
onContextMenu={e => e.target.dispatchEvent(TreeNodeComponent.createClickEvent())} onContextMenu={e => e.target.dispatchEvent(TreeNodeComponent.createClickEvent())}
> >
{props.item.onRenderIcon()} {props.item.onRenderIcon()}
<span className="treeComponentMenuItemLabel">{props.item.text}</span> <span
className={
"treeComponentMenuItemLabel" + (props.item.className ? ` ${props.item.className}Label` : "")
}
>
{props.item.text}
</span>
</div> </div>
), ),
items: node.contextMenu.map((menuItem: TreeNodeMenuItem) => ({ items: node.contextMenu.map((menuItem: TreeNodeMenuItem) => ({
key: menuItem.label, key: menuItem.label,
text: menuItem.label, text: menuItem.label,
disabled: menuItem.isDisabled, disabled: menuItem.isDisabled,
className: menuItem.styleClass,
onClick: menuItem.onClick, onClick: menuItem.onClick,
onRenderIcon: (props: any) => <img src={menuItem.iconSrc} alt="" /> onRenderIcon: (props: any) => <img src={menuItem.iconSrc} alt="" />
})) }))

View File

@ -63,6 +63,15 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
label label
</span> </span>
</div> </div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight <AnimateHeight
animateOpacity={false} animateOpacity={false}
animationStateClasses={ animationStateClasses={
@ -179,6 +188,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
"isBeakVisible": false, "isBeakVisible": false,
"items": Array [ "items": Array [
Object { Object {
"className": undefined,
"disabled": true, "disabled": true,
"key": "menuLabel", "key": "menuLabel",
"onClick": undefined, "onClick": undefined,
@ -201,6 +211,15 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
/> />
</div> </div>
</div> </div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight <AnimateHeight
animateOpacity={false} animateOpacity={false}
animationStateClasses={ animationStateClasses={
@ -261,6 +280,77 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
</div> </div>
`; `;
exports[`TreeNodeComponent renders loading icon 1`] = `
<div
className=" main2 nodeItem "
onClick={[Function]}
onKeyPress={[Function]}
>
<div
className="treeNodeHeader "
data-test="label"
style={
Object {
"paddingLeft": 9,
}
}
tabIndex={-1}
>
<img
alt="label branch is expanded"
className="expandCollapseIcon"
onKeyPress={[Function]}
role="button"
src=""
tabIndex={0}
/>
<span
className="nodeLabel"
title="label"
>
label
</span>
</div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight
animateOpacity={false}
animationStateClasses={
Object {
"animating": "rah-animating",
"animatingDown": "rah-animating--down",
"animatingToHeightAuto": "rah-animating--to-height-auto",
"animatingToHeightSpecific": "rah-animating--to-height-specific",
"animatingToHeightZero": "rah-animating--to-height-zero",
"animatingUp": "rah-animating--up",
"static": "rah-static",
"staticHeightAuto": "rah-static--height-auto",
"staticHeightSpecific": "rah-static--height-specific",
"staticHeightZero": "rah-static--height-zero",
}
}
applyInlineTransitions={true}
delay={0}
duration={200}
easing="ease"
height="auto"
style={Object {}}
>
<div
className="nodeChildren"
data-test="label"
/>
</AnimateHeight>
</div>
`;
exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = ` exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = `
<div <div
className="nodeClassname main12 nodeItem " className="nodeClassname main12 nodeItem "
@ -331,6 +421,15 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
/> />
</div> </div>
</div> </div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight <AnimateHeight
animateOpacity={false} animateOpacity={false}
animationStateClasses={ animationStateClasses={
@ -450,6 +549,15 @@ exports[`TreeNodeComponent renders unsorted children by default 1`] = `
label label
</span> </span>
</div> </div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight <AnimateHeight
animateOpacity={false} animateOpacity={false}
animationStateClasses={ animationStateClasses={

View File

@ -20,7 +20,7 @@
} }
&.showingMenu { &.showingMenu {
background-color: #EEE; background-color: #eee;
} }
.treeMenuEllipsis { .treeMenuEllipsis {
@ -78,3 +78,12 @@
vertical-align: text-bottom; vertical-align: text-bottom;
} }
} }
.loadingIconContainer {
width: 100%;
.loadingIcon {
height: 6px;
margin-left: 38px;
}
}

View File

@ -15,7 +15,7 @@ import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane";
import Database from "./Tree/Database"; import Database from "./Tree/Database";
import DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane"; import DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane";
import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane"; import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane";
import { readOffers, refreshCachedResources } from "../Common/DocumentClientUtilityBase"; import { refreshCachedResources } from "../Common/DocumentClientUtilityBase";
import { readCollection } from "../Common/dataAccess/readCollection"; import { readCollection } from "../Common/dataAccess/readCollection";
import { readDatabases } from "../Common/dataAccess/readDatabases"; import { readDatabases } from "../Common/dataAccess/readDatabases";
import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane"; import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane";
@ -1424,8 +1424,6 @@ export default class Explorer {
// TODO: Refactor // TODO: Refactor
const deferred: Q.Deferred<any> = Q.defer(); const deferred: Q.Deferred<any> = Q.defer();
const refreshDatabases = (offers?: DataModels.Offer[]) => {
this._setLoadingStatusText("Fetching databases..."); this._setLoadingStatusText("Fetching databases...");
readDatabases().then( readDatabases().then(
(databases: DataModels.Database[]) => { (databases: DataModels.Database[]) => {
@ -1440,7 +1438,7 @@ export default class Explorer {
startKey startKey
); );
const currentlySelectedNode: ViewModels.TreeNode = this.selectedNode(); const currentlySelectedNode: ViewModels.TreeNode = this.selectedNode();
const deltaDatabases = this.getDeltaDatabases(databases, offers); const deltaDatabases = this.getDeltaDatabases(databases);
this.addDatabasesToList(deltaDatabases.toAdd); this.addDatabasesToList(deltaDatabases.toAdd);
this.deleteDatabasesFromList(deltaDatabases.toDelete); this.deleteDatabasesFromList(deltaDatabases.toDelete);
this.selectedNode(currentlySelectedNode); this.selectedNode(currentlySelectedNode);
@ -1478,35 +1476,6 @@ export default class Explorer {
); );
} }
); );
};
const offerPromise: Q.Promise<DataModels.Offer[]> = readOffers({ isServerless: this.isServerlessEnabled() });
this._setLoadingStatusText("Fetching offers...");
offerPromise.then(
(offers: DataModels.Offer[]) => {
this._setLoadingStatusText("Successfully fetched offers.");
refreshDatabases(offers);
},
error => {
this._setLoadingStatusText("Failed to fetch offers.");
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)}`
);
}
);
return deferred.promise.then( return deferred.promise.then(
() => { () => {
@ -2103,16 +2072,13 @@ export default class Explorer {
defaultExperience: this.defaultExperience && this.defaultExperience(), defaultExperience: this.defaultExperience && this.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree dataExplorerArea: Constants.Areas.ResourceTree
}); });
databasesToLoad.forEach((database: ViewModels.Database) => { databasesToLoad.forEach(async (database: ViewModels.Database) => {
loadCollectionPromises.push( await database.loadCollections();
database.loadCollections().finally(() => {
const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.rid === database.rid); const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.rid === database.rid);
if (isNewDatabase) { if (isNewDatabase) {
database.expandDatabase(); database.expandDatabase();
} }
this.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().rid === database.rid); this.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().rid === database.rid);
})
);
}); });
Q.all(loadCollectionPromises).done( Q.all(loadCollectionPromises).done(
@ -2257,8 +2223,7 @@ export default class Explorer {
} }
private getDeltaDatabases( private getDeltaDatabases(
updatedDatabaseList: DataModels.Database[], updatedDatabaseList: DataModels.Database[]
updatedOffersList: DataModels.Offer[]
): { toAdd: ViewModels.Database[]; toDelete: ViewModels.Database[] } { ): { toAdd: ViewModels.Database[]; toDelete: ViewModels.Database[] } {
const newDatabases: DataModels.Database[] = _.filter(updatedDatabaseList, (database: DataModels.Database) => { const newDatabases: DataModels.Database[] = _.filter(updatedDatabaseList, (database: DataModels.Database) => {
const databaseExists = _.some( const databaseExists = _.some(
@ -2267,10 +2232,9 @@ export default class Explorer {
); );
return !databaseExists; return !databaseExists;
}); });
const databasesToAdd: ViewModels.Database[] = _.map(newDatabases, (newDatabase: DataModels.Database) => { const databasesToAdd: ViewModels.Database[] = newDatabases.map(
const databaseOffer: DataModels.Offer = this.getOfferForResource(updatedOffersList, newDatabase._self); (newDatabase: DataModels.Database) => new Database(this, newDatabase)
return new Database(this, newDatabase, databaseOffer); );
});
let databasesToDelete: ViewModels.Database[] = []; let databasesToDelete: ViewModels.Database[] = [];
ko.utils.arrayForEach(this.databases(), (database: ViewModels.Database) => { ko.utils.arrayForEach(this.databases(), (database: ViewModels.Database) => {
@ -2320,10 +2284,6 @@ export default class Explorer {
return null; 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<NotebookContentItem> { public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise<NotebookContentItem> {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to upload notebook, but notebook is not enabled"; const error = "Attempt to upload notebook, but notebook is not enabled";
@ -3160,4 +3120,15 @@ export default class Explorer {
} }
} }
} }
public async loadSelectedDatabaseOffer(): Promise<void> {
const database = this.findSelectedDatabase();
await database?.loadOffer();
}
public async loadDatabaseOffers(): Promise<void> {
this.databases()?.forEach(async (database: ViewModels.Database) => {
await database.loadOffer();
});
}
} }

View File

@ -391,31 +391,6 @@ export class CommandBarComponentButtonFactory {
return buttons; 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 && (<any>selectedCollection).onSettingsClick();
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
disabled: container.isDatabaseNodeOrNoneSelected()
};
}
private static createNewNotebookButton(container: Explorer): CommandButtonComponentProps { private static createNewNotebookButton(container: Explorer): CommandButtonComponentProps {
const label = "New Notebook"; const label = "New Notebook";
return { return {

View File

@ -681,7 +681,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
return true; return true;
}; };
public open(databaseId?: string) { public async open(databaseId?: string) {
super.open(); super.open();
// TODO: Figure out if a database level partition split is about to happen once shared throughput read is available // TODO: Figure out if a database level partition split is about to happen once shared throughput read is available
this.formWarnings(""); this.formWarnings("");
@ -715,6 +715,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
dataExplorerArea: Constants.Areas.ContextualPane dataExplorerArea: Constants.Areas.ContextualPane
}; };
await this.container.loadDatabaseOffers();
this._onDatabasesChange(this.container.databases()); this._onDatabasesChange(this.container.databases());
this._setFocus(); this._setFocus();
@ -748,8 +749,6 @@ export default class AddCollectionPane extends ContextualPaneBase {
const cachedDatabaseIdsList = _.map(newDatabaseIds, (database: ViewModels.Database) => { const cachedDatabaseIdsList = _.map(newDatabaseIds, (database: ViewModels.Database) => {
if (database && database.offer && database.offer()) { if (database && database.offer && database.offer()) {
this._databaseOffers.set(database.id(), database.offer()); this._databaseOffers.set(database.id(), database.offer());
} else if (database && database.isDatabaseShared && database.isDatabaseShared()) {
database.readSettings();
} }
return database.id(); return database.id();

View File

@ -268,8 +268,6 @@ export default class CassandraAddCollectionPane extends ContextualPaneBase {
const cachedKeyspaceIdsList = _.map(newKeyspaceIds, (keyspace: ViewModels.Database) => { const cachedKeyspaceIdsList = _.map(newKeyspaceIds, (keyspace: ViewModels.Database) => {
if (keyspace && keyspace.offer && !!keyspace.offer()) { if (keyspace && keyspace.offer && !!keyspace.offer()) {
this.keyspaceOffers.set(keyspace.id(), keyspace.offer()); this.keyspaceOffers.set(keyspace.id(), keyspace.offer());
} else if (keyspace && keyspace.isDatabaseShared && keyspace.isDatabaseShared()) {
keyspace.readSettings();
} }
return keyspace.id(); return keyspace.id();
}); });

View File

@ -132,7 +132,8 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase {
super.resetData(); super.resetData();
} }
public open() { public async open() {
await this.container.loadSelectedDatabaseOffer();
this.recordDeleteFeedback(this.shouldRecordFeedback()); this.recordDeleteFeedback(this.shouldRecordFeedback());
super.open(); super.open();
} }

View File

@ -598,7 +598,6 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
() => { () => {
this.container.isRefreshingExplorer(false); this.container.isRefreshingExplorer(false);
this._setBaseline(); this._setBaseline();
this.database.readSettings();
TelemetryProcessor.traceSuccess( TelemetryProcessor.traceSuccess(
Action.UpdateSettings, Action.UpdateSettings,
{ {
@ -643,8 +642,9 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
}; };
public onActivate(): Q.Promise<any> { public onActivate(): Q.Promise<any> {
return super.onActivate().then(() => { return super.onActivate().then(async () => {
this.database.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings); this.database.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings);
await this.database.loadOffer();
}); });
} }

View File

@ -346,7 +346,6 @@ describe("Settings tab", () => {
const offer: DataModels.Offer = null; const offer: DataModels.Offer = null;
const defaultTtl = 200; const defaultTtl = 200;
const database = new Database(explorer, baseDatabase, null);
const conflictResolutionPolicy = { const conflictResolutionPolicy = {
mode: DataModels.ConflictResolutionMode.LastWriterWins, mode: DataModels.ConflictResolutionMode.LastWriterWins,
conflictResolutionPath: "/_ts" conflictResolutionPath: "/_ts"
@ -507,7 +506,6 @@ describe("Settings tab", () => {
} }
} }
}; };
const database = new Database(explorer, baseDatabase, null);
const container: DataModels.Collection = { const container: DataModels.Collection = {
_rid: "_rid", _rid: "_rid",
_self: "", _self: "",

View File

@ -1270,8 +1270,10 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
} }
public onActivate(): Q.Promise<any> { public onActivate(): Q.Promise<any> {
return super.onActivate().then(() => { return super.onActivate().then(async () => {
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings); this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings);
const database: ViewModels.Database = this.collection.getDatabase();
await database.loadOffer();
}); });
} }

View File

@ -12,8 +12,8 @@ import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import * as Logger from "../../Common/Logger"; import * as Logger from "../../Common/Logger";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { readOffers, readOffer } from "../../Common/DocumentClientUtilityBase";
import { readCollections } from "../../Common/dataAccess/readCollections"; import { readCollections } from "../../Common/dataAccess/readCollections";
import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer";
export default class Database implements ViewModels.Database { export default class Database implements ViewModels.Database {
public nodeKind: string; public nodeKind: string;
@ -27,13 +27,13 @@ export default class Database implements ViewModels.Database {
public isDatabaseShared: ko.Computed<boolean>; public isDatabaseShared: ko.Computed<boolean>;
public selectedSubnodeKind: ko.Observable<ViewModels.CollectionTabKind>; public selectedSubnodeKind: ko.Observable<ViewModels.CollectionTabKind>;
constructor(container: Explorer, data: any, offer: DataModels.Offer) { constructor(container: Explorer, data: any) {
this.nodeKind = "Database"; this.nodeKind = "Database";
this.container = container; this.container = container;
this.self = data._self; this.self = data._self;
this.rid = data._rid; this.rid = data._rid;
this.id = ko.observable(data.id); this.id = ko.observable(data.id);
this.offer = ko.observable(offer); this.offer = ko.observable();
this.collections = ko.observableArray<Collection>(); this.collections = ko.observableArray<Collection>();
this.isDatabaseExpanded = ko.observable<boolean>(false); this.isDatabaseExpanded = ko.observable<boolean>(false);
this.selectedSubnodeKind = ko.observable<ViewModels.CollectionTabKind>(); this.selectedSubnodeKind = ko.observable<ViewModels.CollectionTabKind>();
@ -66,7 +66,7 @@ export default class Database implements ViewModels.Database {
dataExplorerArea: Constants.Areas.Tab, dataExplorerArea: Constants.Areas.Tab,
tabTitle: "Scale" tabTitle: "Scale"
}); });
Q.all([pendingNotificationsPromise, this.readSettings()]).then( pendingNotificationsPromise.then(
(data: any) => { (data: any) => {
const pendingNotification: DataModels.Notification = data && data[0]; const pendingNotification: DataModels.Notification = data && data[0];
settingsTab = new DatabaseSettingsTab({ settingsTab = new DatabaseSettingsTab({
@ -121,80 +121,6 @@ export default class Database implements ViewModels.Database {
} }
}; };
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[]> = 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 { public isDatabaseNodeSelected(): boolean {
return ( return (
!this.isDatabaseExpanded() && !this.isDatabaseExpanded() &&
@ -219,23 +145,13 @@ export default class Database implements ViewModels.Database {
}); });
} }
public expandCollapseDatabase() { public async expandDatabase() {
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() {
if (this.isDatabaseExpanded()) { if (this.isDatabaseExpanded()) {
return; return;
} }
this.loadCollections(); await this.loadOffer();
await this.loadCollections();
this.isDatabaseExpanded(true); this.isDatabaseExpanded(true);
TelemetryProcessor.trace(Action.ExpandTreeNode, ActionModifiers.Mark, { TelemetryProcessor.trace(Action.ExpandTreeNode, ActionModifiers.Mark, {
description: "Database node", description: "Database node",
@ -259,14 +175,10 @@ export default class Database implements ViewModels.Database {
}); });
} }
public loadCollections(): Q.Promise<void> { public async loadCollections(): Promise<void> {
let collectionVMs: Collection[] = []; const collectionVMs: Collection[] = [];
let deferred: Q.Deferred<void> = Q.defer<void>(); const collections: DataModels.Collection[] = await readCollections(this.id());
const deltaCollections = this.getDeltaCollections(collections);
readCollections(this.id()).then(
(collections: DataModels.Collection[]) => {
let collectionsToAddVMPromises: Q.Promise<any>[] = [];
let deltaCollections = this.getDeltaCollections(collections);
deltaCollections.toAdd.forEach((collection: DataModels.Collection) => { deltaCollections.toAdd.forEach((collection: DataModels.Collection) => {
const collectionVM: Collection = new Collection(this.container, this.id(), collection, null, null); const collectionVM: Collection = new Collection(this.container, this.id(), collection, null, null);
@ -276,15 +188,6 @@ export default class Database implements ViewModels.Database {
//merge collections //merge collections
this.addCollectionsToList(collectionVMs); this.addCollectionsToList(collectionVMs);
this.deleteCollectionsFromList(deltaCollections.toDelete); this.deleteCollectionsFromList(deltaCollections.toDelete);
deferred.resolve();
},
(error: any) => {
deferred.reject(error);
}
);
return deferred.promise;
} }
public openAddCollection(database: Database, event: MouseEvent) { 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); return _.find(this.collections(), (collection: ViewModels.Collection) => collection.id() === collectionId);
} }
public async loadOffer(): Promise<void> {
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<DataModels.Notification> { private _getPendingThroughputSplitNotification(): Q.Promise<DataModels.Notification> {
if (!this.container) { if (!this.container) {
return Q.resolve(undefined); return Q.resolve(undefined);
@ -387,8 +301,4 @@ export default class Database implements ViewModels.Database {
this.collections(collectionsToKeep); this.collections(collectionsToKeep);
} }
private _getOfferForDatabase(offers: DataModels.Offer[], database: DataModels.Database): DataModels.Offer {
return _.find(offers, (offer: DataModels.Offer) => offer.resource === database._self);
}
} }

View File

@ -170,14 +170,17 @@ export class ResourceTreeAdapter implements ReactAdapter {
children: [], children: [],
isSelected: () => this.isDataNodeSelected(database.rid, "Database", undefined), isSelected: () => this.isDataNodeSelected(database.rid, "Database", undefined),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(this.container, database), contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(this.container, database),
onClick: isExpanded => { onClick: async isExpanded => {
// Rewritten version of expandCollapseDatabase(): // Rewritten version of expandCollapseDatabase():
if (!isExpanded) { if (isExpanded) {
database.expandDatabase();
database.loadCollections();
} else {
database.collapseDatabase(); database.collapseDatabase();
} else {
if (databaseNode.children?.length === 0) {
databaseNode.isLoading = true;
} }
await database.expandDatabase();
}
databaseNode.isLoading = false;
database.selectDatabase(); database.selectDatabase();
this.container.onUpdateTabsButtons([]); this.container.onUpdateTabsButtons([]);
this.container.tabsManager.refreshActiveTab( this.container.tabsManager.refreshActiveTab(
@ -203,6 +206,12 @@ export class ResourceTreeAdapter implements ReactAdapter {
databaseNode.children.push(this.buildCollectionNode(database, collection)) 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; return databaseNode;
}); });

View File

@ -56,24 +56,27 @@ describe('Collection Add and Delete SQL spec', () => {
await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true }); await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true });
await frame.click(`div[data-test="${dbId}"]`); 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 // delete container
// click context menu for 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.click(`div[data-test="${collectionId}"] > div > button`);
await frame.waitFor(2000)
// click delete container // click delete container
await frame.waitForSelector('body > div.ms-Layer.ms-Layer--fixed'); await frame.waitFor('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]', { visible: true });
await frame.waitFor(1000); await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]');
const elements = await frame.$$('span[class="treeComponentMenuItemLabel"]')
await elements[4].click();
// confirm delete container // confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true })
await frame.type('input[data-test="confirmCollectionId"]', collectionId.trim()); await frame.type('input[data-test="confirmCollectionId"]', collectionId.trim());
// click delete // click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true })
await frame.click('input[data-test="deleteCollection"]'); await frame.click('input[data-test="deleteCollection"]');
await frame.waitFor(5000); await frame.waitFor(5000);
await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true }); 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(); await button.asElement().click();
// click delete database // click delete database
await frame.waitFor(1000); await frame.waitFor('span[class="treeComponentMenuItemLabel deleteDatabaseMenuItemLabel"]');
const dbElements = await frame.$$('span[class="treeComponentMenuItemLabel"]') await frame.click('span[class="treeComponentMenuItemLabel deleteDatabaseMenuItemLabel"]');
await dbElements[1].click();
// confirm delete database // confirm delete database
await frame.type('input[data-test="confirmDatabaseId"]', dbId.trim()); await frame.type('input[data-test="confirmDatabaseId"]', dbId.trim());