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;
}
export interface ReadDatabaseOfferParams {
databaseId: string;
databaseResourceId?: string;
isServerless?: boolean;
offerId?: string;
}
export interface Notification {
id: string;
kind: string;

View File

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

View File

@ -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;

View File

@ -159,4 +159,20 @@ describe("TreeNodeComponent", () => {
const wrapper = shallow(<TreeNodeComponent {...props} />);
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 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<TreeNodeComponentProps, T
)}
{node.contextMenu && this.renderContextMenuButton(node)}
</div>
<div className="loadingIconContainer">
<img className="loadingIcon" src={LoadingIndicator_3Squares} hidden={!this.props.node.isLoading} />
</div>
{node.children && (
<AnimateHeight duration={TreeNodeComponent.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0}>
<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())}
>
{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>
),
items: node.contextMenu.map((menuItem: TreeNodeMenuItem) => ({
key: menuItem.label,
text: menuItem.label,
disabled: menuItem.isDisabled,
className: menuItem.styleClass,
onClick: menuItem.onClick,
onRenderIcon: (props: any) => <img src={menuItem.iconSrc} alt="" />
}))

View File

@ -63,6 +63,15 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
label
</span>
</div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight
animateOpacity={false}
animationStateClasses={
@ -179,6 +188,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
"isBeakVisible": false,
"items": Array [
Object {
"className": undefined,
"disabled": true,
"key": "menuLabel",
"onClick": undefined,
@ -201,6 +211,15 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
/>
</div>
</div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight
animateOpacity={false}
animationStateClasses={
@ -261,6 +280,77 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
</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`] = `
<div
className="nodeClassname main12 nodeItem "
@ -331,6 +421,15 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
/>
</div>
</div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight
animateOpacity={false}
animationStateClasses={
@ -450,6 +549,15 @@ exports[`TreeNodeComponent renders unsorted children by default 1`] = `
label
</span>
</div>
<div
className="loadingIconContainer"
>
<img
className="loadingIcon"
hidden={true}
src=""
/>
</div>
<AnimateHeight
animateOpacity={false}
animationStateClasses={

View File

@ -20,7 +20,7 @@
}
&.showingMenu {
background-color: #EEE;
background-color: #eee;
}
.treeMenuEllipsis {
@ -78,3 +78,12 @@
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 DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane";
import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane";
import { readOffers, refreshCachedResources } from "../Common/DocumentClientUtilityBase";
import { refreshCachedResources } from "../Common/DocumentClientUtilityBase";
import { readCollection } from "../Common/dataAccess/readCollection";
import { readDatabases } from "../Common/dataAccess/readDatabases";
import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane";
@ -1424,71 +1424,40 @@ export default class Explorer {
// TODO: Refactor
const deferred: Q.Deferred<any> = 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<DataModels.Offer[]> = 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<NotebookContentItem> {
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<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;
}
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 {
const label = "New Notebook";
return {

View File

@ -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();

View File

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

View File

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

View File

@ -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<any> {
return super.onActivate().then(() => {
return super.onActivate().then(async () => {
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 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: "",

View File

@ -1270,8 +1270,10 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
}
public onActivate(): Q.Promise<any> {
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();
});
}

View File

@ -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<boolean>;
public selectedSubnodeKind: ko.Observable<ViewModels.CollectionTabKind>;
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<Collection>();
this.isDatabaseExpanded = ko.observable<boolean>(false);
this.selectedSubnodeKind = ko.observable<ViewModels.CollectionTabKind>();
@ -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<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 {
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<void> {
let collectionVMs: Collection[] = [];
let deferred: Q.Deferred<void> = Q.defer<void>();
public async loadCollections(): Promise<void> {
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<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);
});
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<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> {
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);
}
}

View File

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

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.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());