diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 2f831b77e..d0ca8ef40 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -120,6 +120,7 @@ export class Features { public static readonly enableDatabaseSettingsTabV1 = "enabledbsettingsv1"; public static readonly selfServeType = "selfservetype"; public static readonly enableKOPanel = "enablekopanel"; + public static readonly enableReactPane = "enablereactpane"; } // flight names returned from the portal are always lowercase diff --git a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.test.tsx b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.test.tsx index 6193ac23b..304626933 100644 --- a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.test.tsx +++ b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.test.tsx @@ -6,6 +6,7 @@ describe("CollapsibleSectionComponent", () => { it("renders", () => { const props: CollapsibleSectionProps = { title: "Sample title", + isExpandedByDefault: true, }; const wrapper = shallow(); diff --git a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx index ef59eab1c..9b96f36f4 100644 --- a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx +++ b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx @@ -1,9 +1,10 @@ import { Icon, Label, Stack } from "office-ui-fabric-react"; import * as React from "react"; -import { accordionIconStyles, accordionStackTokens } from "../Settings/SettingsRenderUtils"; +import { accordionStackTokens } from "../Settings/SettingsRenderUtils"; export interface CollapsibleSectionProps { title: string; + isExpandedByDefault: boolean; } export interface CollapsibleSectionState { @@ -14,7 +15,7 @@ export class CollapsibleSectionComponent extends React.Component - - + + {this.state.isExpanded && this.props.children} diff --git a/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap b/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap index 60bd1fe5d..95d3c46bf 100644 --- a/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap +++ b/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap @@ -11,16 +11,10 @@ exports[`CollapsibleSectionComponent renders 1`] = ` "childrenGap": 10, } } + verticalAlign="center" > Sample title diff --git a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx index b110723d3..24315af92 100644 --- a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx @@ -23,7 +23,6 @@ import { ITextStyles, IDetailsRowStyles, IStackStyles, - IIconStyles, IDetailsListStyles, IDropdownStyles, ISeparatorStyles, @@ -116,8 +115,6 @@ export const addMongoIndexSubElementsTokens: IStackTokens = { childrenGap: 20, }; -export const accordionIconStyles: IIconStyles = { root: { paddingTop: 7 } }; - export const mediumWidthStackStyles: IStackStyles = { root: { width: 600 } }; export const shortWidthTextFieldStyles: Partial = { root: { paddingLeft: 10, width: 210 } }; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx index 5493f7dda..48035529c 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx @@ -239,7 +239,7 @@ export class MongoIndexingPolicyComponent extends React.Component - + { <> - + {indexesToBeDropped.length > 0 && ( diff --git a/src/Explorer/Controls/ThroughputInput/ThroughputInput.less b/src/Explorer/Controls/ThroughputInput/ThroughputInput.less new file mode 100644 index 000000000..dec618d7b --- /dev/null +++ b/src/Explorer/Controls/ThroughputInput/ThroughputInput.less @@ -0,0 +1,20 @@ +@import "../../../../less/Common/Constants"; + +.throughputInputContainer { + .throughputInputRadioBtn { + margin: 0; + } +} + +.throughputInputRadioBtnLabel { + font-size: @mediumFontSize; + padding: 0 @LargeSpace 0 @SmallSpace; +} + +.throughputInputSpacing { + margin-bottom: @SmallSpace; + + & > * { + margin-bottom: @SmallSpace; + } +} diff --git a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx new file mode 100644 index 000000000..681b2d1c3 --- /dev/null +++ b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx @@ -0,0 +1,302 @@ +import { Checkbox, DirectionalHint, Icon, Link, Stack, Text, TextField, TooltipHost } from "office-ui-fabric-react"; +import React from "react"; +import * as Constants from "../../../Common/Constants"; +import * as SharedConstants from "../../../Shared/Constants"; +import { userContext } from "../../../UserContext"; +import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils"; +import * as PricingUtils from "../../../Utils/PricingUtils"; + +export interface ThroughputInputProps { + isDatabase: boolean; + showFreeTierExceedThroughputTooltip: boolean; + setThroughputValue: (throughput: number) => void; + setIsAutoscale: (isAutoscale: boolean) => void; + onCostAcknowledgeChange: (isAcknowledged: boolean) => void; +} + +export interface ThroughputInputState { + isAutoscaleSelected: boolean; + throughput: number; + isCostAcknowledged: boolean; +} + +export class ThroughputInput extends React.Component { + constructor(props: ThroughputInputProps) { + super(props); + + this.state = { + isAutoscaleSelected: true, + throughput: AutoPilotUtils.minAutoPilotThroughput, + isCostAcknowledged: false, + }; + + this.props.setThroughputValue(AutoPilotUtils.minAutoPilotThroughput); + this.props.setIsAutoscale(true); + } + + render(): JSX.Element { + return ( +
+ + + + {this.getThroughputLabelText()} + + + + + + + + + Autoscale + + + Manual + + + {this.state.isAutoscaleSelected && ( + + + Provision maximum RU/s required by this resource. Estimate your required RU/s with  + + capacity calculator + + . + + + + + Max RU/s + + + + + + + this.onThroughputValueChange(newInput)} + step={AutoPilotUtils.autoPilotIncrementStep} + min={AutoPilotUtils.minAutoPilotThroughput} + value={this.state.throughput.toString()} + aria-label="Max request units per second" + required={true} + /> + + + Your {this.props.isDatabase ? "database" : "container"} throughput will automatically scale from{" "} + + {AutoPilotUtils.getMinRUsBasedOnUserInput(this.state.throughput)} RU/s (10% of max RU/s) -{" "} + {this.state.throughput} RU/s + {" "} + based on usage. + + + )} + + {!this.state.isAutoscaleSelected && ( + + + Estimate your required RU/s with  + + capacity calculator + + . + + + SharedConstants.CollectionCreation.DefaultCollectionRUs400 + ? "The first 400 RU/s in this account are free. Billing will apply to any throughput beyond 400 RU/s." + : undefined + } + > + this.onThroughputValueChange(newInput)} + step={100} + min={SharedConstants.CollectionCreation.DefaultCollectionRUs400} + max={userContext.isTryCosmosDBSubscription ? Constants.TryCosmosExperience.maxRU : Infinity} + value={this.state.throughput.toString()} + aria-label="Max request units per second" + required={true} + /> + + + )} + + + + {this.state.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && ( + + , isChecked: boolean) => { + this.setState({ isCostAcknowledged: isChecked }); + this.props.onCostAcknowledgeChange(isChecked); + }} + /> + + {this.getCostAcknowledgeText()} + + + )} +
+ ); + } + + private getThroughputLabelText(): string { + if (this.state.isAutoscaleSelected) { + return AutoPilotUtils.getAutoPilotHeaderText(); + } + + const minRU: string = SharedConstants.CollectionCreation.DefaultCollectionRUs400.toLocaleString(); + const maxRU: string = userContext.isTryCosmosDBSubscription + ? Constants.TryCosmosExperience.maxRU.toLocaleString() + : "unlimited"; + return this.state.isAutoscaleSelected + ? AutoPilotUtils.getAutoPilotHeaderText() + : `Throughput (${minRU} - ${maxRU} RU/s)`; + } + + private onThroughputValueChange(newInput: string): void { + const newThroughput = parseInt(newInput); + this.setState({ throughput: newThroughput }); + this.props.setThroughputValue(newThroughput); + } + + private getAutoScaleTooltip(): string { + return `After the first ${AutoPilotUtils.getStorageBasedOnUserInput( + this.state.throughput + )} GB of data stored, the max + RU/s will be automatically upgraded based on the new storage value.`; + } + + private getCostAcknowledgeText(): string { + const databaseAccount = userContext.databaseAccount; + if (!databaseAccount || !databaseAccount.properties) { + return ""; + } + + const numberOfRegions: number = databaseAccount.properties.readLocations?.length || 1; + const multimasterEnabled: boolean = databaseAccount.properties.enableMultipleWriteLocations; + + return PricingUtils.getEstimatedSpendAcknowledgeString( + this.state.throughput, + userContext.portalEnv, + numberOfRegions, + multimasterEnabled, + this.state.isAutoscaleSelected + ); + } + + private onAutoscaleRadioBtnChange(event: React.ChangeEvent): void { + if (event.target.checked && !this.state.isAutoscaleSelected) { + this.setState({ isAutoscaleSelected: true, throughput: AutoPilotUtils.minAutoPilotThroughput }); + this.props.setIsAutoscale(true); + } + } + + private onManualRadioBtnChange(event: React.ChangeEvent): void { + if (event.target.checked && this.state.isAutoscaleSelected) { + this.setState({ + isAutoscaleSelected: false, + throughput: SharedConstants.CollectionCreation.DefaultCollectionRUs400, + }); + this.props.setIsAutoscale(false); + this.props.setThroughputValue(SharedConstants.CollectionCreation.DefaultCollectionRUs400); + } + } +} + +interface CostEstimateTextProps { + requestUnits: number; + isAutoscale: boolean; +} + +const CostEstimateText: React.FunctionComponent = (props: CostEstimateTextProps) => { + const { requestUnits, isAutoscale } = props; + const databaseAccount = userContext.databaseAccount; + if (!databaseAccount || !databaseAccount.properties) { + return <>; + } + + const serverId: string = userContext.portalEnv; + const numberOfRegions: number = databaseAccount.properties.readLocations?.length || 1; + const multimasterEnabled: boolean = databaseAccount.properties.enableMultipleWriteLocations; + const hourlyPrice: number = PricingUtils.computeRUUsagePriceHourly({ + serverId, + requestUnits, + numberOfRegions, + multimasterEnabled, + isAutoscale, + }); + const dailyPrice: number = hourlyPrice * 24; + const monthlyPrice: number = hourlyPrice * SharedConstants.hoursInAMonth; + const currency: string = PricingUtils.getPriceCurrency(serverId); + const currencySign: string = PricingUtils.getCurrencySign(serverId); + const multiplier = PricingUtils.getMultimasterMultiplier(numberOfRegions, multimasterEnabled); + const pricePerRu = isAutoscale + ? PricingUtils.getAutoscalePricePerRu(serverId, multiplier) * multiplier + : PricingUtils.getPricePerRu(serverId) * multiplier; + + if (isAutoscale) { + return ( + + Estimated monthly cost ({currency}):{" "} + + {currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice / 10)} -{" "} + {currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)}{" "} + + ({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits / 10} - {requestUnits}{" "} + RU/s, {currencySign + pricePerRu}/RU) + + ); + } + + return ( + + Cost ({currency}):{" "} + + {currencySign + PricingUtils.calculateEstimateNumber(hourlyPrice)} hourly /{" "} + {currencySign + PricingUtils.calculateEstimateNumber(dailyPrice)} daily /{" "} + {currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)} monthly{" "} + + ({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits}RU/s,{" "} + {currencySign + pricePerRu}/RU) +
+ {PricingUtils.estimatedCostDisclaimer} +
+ ); +}; diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index cfef5ab9c..a32005700 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -48,6 +48,7 @@ import { FileSystemUtil } from "./Notebook/FileSystemUtil"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import { NotebookUtil } from "./Notebook/NotebookUtil"; import AddCollectionPane from "./Panes/AddCollectionPane"; +import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; import AddDatabasePane from "./Panes/AddDatabasePane"; import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane"; import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane"; @@ -2392,10 +2393,12 @@ export default class Explorer { public onNewCollectionClicked(): void { if (this.isPreferredApiCassandra()) { this.cassandraAddCollectionPane.open(); + } else if (this.isFeatureEnabled(Constants.Features.enableReactPane)) { + this.openAddCollectionPanel(); } else { this.addCollectionPane.open(this.selectedDatabaseId()); + document.getElementById("linkAddCollection").focus(); } - document.getElementById("linkAddCollection").focus(); } private refreshCommandBarButtons(): void { @@ -2535,4 +2538,16 @@ export default class Explorer { /> ); } + + public async openAddCollectionPanel(): Promise { + await this.loadDatabaseOffers(); + this.openSidePanel( + "New Collection", + this.closeSidePanel()} + openNotificationConsole={() => this.expandConsole()} + /> + ); + } } diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel.tsx new file mode 100644 index 000000000..548e6c39f --- /dev/null +++ b/src/Explorer/Panes/AddCollectionPanel.tsx @@ -0,0 +1,1018 @@ +import { + ActionButton, + Checkbox, + DefaultButton, + DirectionalHint, + Dropdown, + Icon, + IconButton, + IDropdownOption, + Link, + Stack, + Text, + TooltipHost, +} from "office-ui-fabric-react"; +import React from "react"; +import * as Constants from "../../Common/Constants"; +import { createCollection } from "../../Common/dataAccess/createCollection"; +import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; +import { configContext, Platform } from "../../ConfigContext"; +import * as DataModels from "../../Contracts/DataModels"; +import { SubscriptionType } from "../../Contracts/SubscriptionType"; +import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType"; +import { CollectionCreation, IndexingPolicies } from "../../Shared/Constants"; +import { Action } from "../../Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import { userContext } from "../../UserContext"; +import { getUpsellMessage } from "../../Utils/PricingUtils"; +import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput"; +import Explorer from "../Explorer"; +import { PanelFooterComponent } from "./PanelFooterComponent"; +import { PanelInfoErrorComponent } from "./PanelInfoErrorComponent"; +import { PanelLoadingScreen } from "./PanelLoadingScreen"; + +export interface AddCollectionPanelProps { + explorer: Explorer; + closePanel: () => void; + openNotificationConsole: () => void; +} + +export interface AddCollectionPanelState { + createNewDatabase: boolean; + newDatabaseId: string; + isSharedThroughputChecked: boolean; + selectedDatabaseId: string; + collectionId: string; + enableIndexing: boolean; + isSharded: boolean; + partitionKey: string; + enableDedicatedThroughput: boolean; + createMongoWildCardIndex: boolean; + useHashV1: boolean; + enableAnalyticalStore: boolean; + uniqueKeys: string[]; + errorMessage: string; + showErrorDetails: boolean; + isExecuting: boolean; +} + +export class AddCollectionPanel extends React.Component { + private newDatabaseThroughput: number; + private isNewDatabaseAutoscale: boolean; + private collectionThroughput: number; + private isCollectionAutoscale: boolean; + private isCostAcknowledged: boolean; + + constructor(props: AddCollectionPanelProps) { + super(props); + + this.state = { + createNewDatabase: userContext.defaultExperience !== DefaultAccountExperienceType.Table, + newDatabaseId: "", + isSharedThroughputChecked: this.getSharedThroughputDefault(), + selectedDatabaseId: + userContext.defaultExperience === DefaultAccountExperienceType.Table + ? CollectionCreation.TablesAPIDefaultDatabase + : undefined, + collectionId: "", + enableIndexing: true, + isSharded: userContext.defaultExperience !== DefaultAccountExperienceType.Table, + partitionKey: "", + enableDedicatedThroughput: false, + createMongoWildCardIndex: true, + useHashV1: false, + enableAnalyticalStore: false, + uniqueKeys: [], + errorMessage: "", + showErrorDetails: false, + isExecuting: false, + }; + } + + render(): JSX.Element { + return ( +
+ {this.state.errorMessage && ( + + )} + + {!this.state.errorMessage && this.isFreeTierAccount() && ( + + )} + +
+ + + + + + + {`${this.getCollectionName()} id`} + + + + + + + ) => + this.setState({ collectionId: event.target.value }) + } + /> + + + {this.shouldShowIndexingOptionsForFreeTierAccount() && ( + + + + + Indexing + + + + + + Automatic + + + Off + + + + {this.getFreeTierIndexingText()}{" "} + + Learn more + + + + )} + + {userContext.defaultExperience === DefaultAccountExperienceType.MongoDB && + (!this.state.isSharedThroughputChecked || + this.props.explorer.isFixedCollectionWithSharedThroughputSupported()) && ( + + + + + Sharding options + + + + + + + + + Unsharded (20GB limit) + + + Sharded + + + )} + + {this.state.isSharded && ( + + + + + {this.getPartitionKeyName()} + + + + + + + ) => + this.setState({ partitionKey: event.target.value }) + } + /> + + )} + + {!this.isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && ( + + , isChecked: boolean) => + this.setState({ enableDedicatedThroughput: isChecked }) + } + /> + + + + + )} + + {this.shouldShowCollectionThroughputInput() && ( + (this.collectionThroughput = throughput)} + setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)} + onCostAcknowledgeChange={(isAcknowledged: boolean) => { + this.isCostAcknowledged = isAcknowledged; + }} + /> + )} + + {userContext.defaultExperience === DefaultAccountExperienceType.DocumentDB && ( + + + + Unique keys + + + + + + + {this.state.uniqueKeys.map( + (uniqueKey: string, i: number): JSX.Element => { + return ( + + ) => { + const uniqueKeys = this.state.uniqueKeys.map((uniqueKey: string, j: number) => { + if (i === j) { + return event.target.value; + } + return uniqueKey; + }); + this.setState({ uniqueKeys }); + }} + /> + + { + const uniqueKeys = this.state.uniqueKeys.filter((uniqueKey, j) => i !== j); + this.setState({ uniqueKeys }); + }} + /> + + ); + } + )} + + this.setState({ uniqueKeys: [...this.state.uniqueKeys, ""] })} + > + Add unique key + + + )} + + + + {this.props.explorer.isEnableMongoCapabilityPresent() && ( + + + + + Indexing + + + + + + + , isChecked: boolean) => + this.setState({ createMongoWildCardIndex: isChecked }) + } + /> + + )} + + {userContext.defaultExperience === DefaultAccountExperienceType.DocumentDB && ( + + + , isChecked: boolean) => + this.setState({ useHashV1: isChecked }) + } + /> + + My application uses an older Cosmos .NET or Java SDK version (.NET V1 or Java V2) + + + + + To ensure compatibility with older SDKs, the created container will use a legacy partitioning scheme + that supports partition key values of size up to 100 bytes.{" "} + + Learn more + + + + )} + + {this.shouldShowAnalyticalStoreOptions() && ( + + + + Analytical store + + + + + + + + + On + + + Off + + + {!this.isSynapseLinkEnabled() && ( + + + Azure Synapse Link is required for creating an analytical store container. Enable Synapse Link + for this Cosmos DB account.{" "} + + Learn more + + + this.props.explorer.openEnableSynapseLinkDialog()} + style={{ height: 27, width: 80 }} + styles={{ label: { fontSize: 12 } }} + /> + + )} + + )} + + +
+ + + + {this.state.isExecuting && } + + ); + } + + private getDatabaseOptions(): IDropdownOption[] { + return this.props.explorer?.databases()?.map((database) => ({ + key: database.id(), + text: database.id(), + })); + } + + private getCollectionName(): string { + switch (userContext.defaultExperience) { + case DefaultAccountExperienceType.DocumentDB: + return "Container"; + case DefaultAccountExperienceType.MongoDB: + return "Collection"; + case DefaultAccountExperienceType.Cassandra: + case DefaultAccountExperienceType.Table: + return "Table"; + case DefaultAccountExperienceType.Graph: + return "Graph"; + default: + throw new Error(`Unsupported default experience type: ${userContext.defaultExperience}`); + } + } + + private getPartitionKeyName(): string { + return userContext.defaultExperience === DefaultAccountExperienceType.MongoDB ? "Shard key" : "Partition key"; + } + + private getPartitionKeyPlaceHolder(): string { + switch (userContext.defaultExperience) { + case DefaultAccountExperienceType.MongoDB: + return "e.g., address.zipCode"; + case DefaultAccountExperienceType.Graph: + return "e.g., /address"; + default: + return "e.g., /address/zipCode"; + } + } + + private onCreateNewDatabaseRadioBtnChange(event: React.ChangeEvent): void { + if (event.target.checked && !this.state.createNewDatabase) { + this.setState({ + createNewDatabase: true, + }); + } + } + + private onUseExistingDatabaseRadioBtnChange(event: React.ChangeEvent): void { + if (event.target.checked && this.state.createNewDatabase) { + this.setState({ + createNewDatabase: false, + }); + } + } + + private onUnshardedRadioBtnChange(event: React.ChangeEvent): void { + if (event.target.checked && this.state.isSharded) { + this.setState({ + isSharded: false, + }); + } + } + + private onShardedRadioBtnChange(event: React.ChangeEvent): void { + if (event.target.checked && !this.state.isSharded) { + this.setState({ + isSharded: true, + }); + } + } + + private onEnableAnalyticalStoreRadioBtnChange(event: React.ChangeEvent): void { + if (event.target.checked && !this.state.enableAnalyticalStore) { + this.setState({ + enableAnalyticalStore: true, + }); + } + } + + private onDisableAnalyticalStoreRadioBtnChange(event: React.ChangeEvent): void { + if (event.target.checked && this.state.enableAnalyticalStore) { + this.setState({ + enableAnalyticalStore: false, + }); + } + } + + private onTurnOnIndexing(event: React.ChangeEvent): void { + if (event.target.checked && !this.state.enableIndexing) { + this.setState({ + enableIndexing: true, + }); + } + } + + private onTurnOffIndexing(event: React.ChangeEvent): void { + if (event.target.checked && this.state.enableIndexing) { + this.setState({ + enableIndexing: false, + }); + } + } + + private isSelectedDatabaseSharedThroughput(): boolean { + if (!this.state.selectedDatabaseId) { + return false; + } + + const selectedDatabase = this.props.explorer + .databases() + ?.find((database) => database.id() === this.state.selectedDatabaseId); + return !!selectedDatabase?.offer(); + } + + private isFreeTierAccount(): boolean { + return userContext.databaseAccount?.properties?.enableFreeTier; + } + + private isServerlessAccount(): boolean { + return userContext.databaseAccount.properties?.capabilities?.some( + (capability) => capability.name === Constants.CapabilityNames.EnableServerless + ); + } + + private getSharedThroughputDefault(): boolean { + return userContext.subscriptionType !== SubscriptionType.EA && !this.isServerlessAccount(); + } + + private getFreeTierIndexingText(): string { + return this.state.enableIndexing + ? "All properties in your documents will be indexed by default for flexible and efficient queries." + : "Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations."; + } + + private shouldShowCollectionThroughputInput(): boolean { + if (this.isServerlessAccount()) { + return false; + } + + if (this.state.createNewDatabase) { + return !this.state.isSharedThroughputChecked; + } + + if (this.state.enableDedicatedThroughput) { + return true; + } + + return this.state.selectedDatabaseId && !this.isSelectedDatabaseSharedThroughput(); + } + + private shouldShowIndexingOptionsForFreeTierAccount(): boolean { + if (!this.isFreeTierAccount()) { + return false; + } + + return this.state.createNewDatabase + ? this.state.isSharedThroughputChecked + : this.isSelectedDatabaseSharedThroughput(); + } + + private shouldShowAnalyticalStoreOptions(): boolean { + if (configContext.platform === Platform.Emulator) { + return false; + } + + if (this.isServerlessAccount()) { + return false; + } + + switch (userContext.defaultExperience) { + case DefaultAccountExperienceType.DocumentDB: + case DefaultAccountExperienceType.MongoDB: + return true; + case DefaultAccountExperienceType.Cassandra: + return this.props.explorer.hasStorageAnalyticsAfecFeature(); + default: + return false; + } + } + + private isSynapseLinkEnabled(): boolean { + const properties = userContext.databaseAccount?.properties; + + if (!properties) { + return false; + } + + if (properties.enableAnalyticalStorage) { + return true; + } + + return properties.capabilities.some( + (capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics + ); + } + + private parseUniqueKeys(): DataModels.UniqueKeyPolicy { + if (this.state.uniqueKeys?.length === 0) { + return undefined; + } + + const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = { uniqueKeys: [] }; + this.state.uniqueKeys.forEach((uniqueKey) => { + if (uniqueKey) { + const validPaths: string[] = uniqueKey.split(",")?.filter((path) => path?.length > 0); + const trimmedPaths: string[] = validPaths?.map((path) => path.trim()); + if (trimmedPaths?.length > 0) { + if (userContext.defaultExperience === DefaultAccountExperienceType.MongoDB) { + trimmedPaths.map((path) => { + const transformedPath = path.split(".").join("/"); + if (transformedPath[0] !== "/") { + return "/" + transformedPath; + } + return transformedPath; + }); + } + uniqueKeyPolicy.uniqueKeys.push({ paths: trimmedPaths }); + } + } + }); + + return uniqueKeyPolicy; + } + + private validateInputs(): boolean { + if (!this.state.createNewDatabase && !this.state.selectedDatabaseId) { + this.setState({ errorMessage: "Please select an existing database" }); + return false; + } + + const throughput = this.state.createNewDatabase ? this.newDatabaseThroughput : this.collectionThroughput; + if (throughput > CollectionCreation.DefaultCollectionRUs100K && !this.isCostAcknowledged) { + const errorMessage = this.isNewDatabaseAutoscale + ? "Please acknowledge the estimated monthly spend." + : "Please acknowledge the estimated daily spend."; + this.setState({ errorMessage }); + return false; + } + + if ( + userContext.defaultExperience === DefaultAccountExperienceType.Graph && + (this.state.partitionKey === "/id" || this.state.partitionKey === "/label") + ) { + this.setState({ errorMessage: "/id and /label as partition keys are not allowed for graph." }); + return false; + } + + return true; + } + + private getAnalyticalStorageTtl(): number { + if (!this.shouldShowAnalyticalStoreOptions()) { + return undefined; + } + + if (this.state.enableAnalyticalStore) { + // TODO: always default to 90 days once the backend hotfix is deployed + return this.props.explorer.isFeatureEnabled(Constants.Features.ttl90Days) + ? Constants.AnalyticalStorageTtl.Days90 + : Constants.AnalyticalStorageTtl.Infinite; + } + + return Constants.AnalyticalStorageTtl.Disabled; + } + + private async submit(event: React.FormEvent): Promise { + event.preventDefault(); + + if (!this.validateInputs()) { + return; + } + + const collectionId: string = this.state.collectionId.trim(); + let databaseId = this.state.createNewDatabase ? this.state.newDatabaseId.trim() : this.state.selectedDatabaseId; + let partitionKeyString = this.state.partitionKey.trim(); + + if (userContext.defaultExperience === DefaultAccountExperienceType.Table) { + // Table require fixed Database: TablesDB, and fixed Partition Key: /'$pk' + databaseId = CollectionCreation.TablesAPIDefaultDatabase; + partitionKeyString = "/'$pk'"; + } + + const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = this.parseUniqueKeys(); + const partitionKeyVersion = this.state.useHashV1 ? undefined : 2; + const partitionKey: DataModels.PartitionKey = partitionKeyString + ? { + paths: [partitionKeyString], + kind: Constants.BackendDefaults.partitionKeyKind, + version: partitionKeyVersion, + } + : undefined; + + const indexingPolicy: DataModels.IndexingPolicy = this.state.enableIndexing + ? IndexingPolicies.AllPropertiesIndexed + : IndexingPolicies.SharedDatabaseDefault; + + const telemetryData = { + database: { + id: databaseId, + new: this.state.createNewDatabase, + shared: this.state.createNewDatabase + ? this.state.isSharedThroughputChecked + : this.isSelectedDatabaseSharedThroughput(), + }, + collection: { + id: this.state.collectionId, + throughput: this.collectionThroughput, + isAutoscale: this.isCollectionAutoscale, + partitionKey, + uniqueKeyPolicy, + collectionWithDedicatedThroughput: this.state.enableDedicatedThroughput, + }, + subscriptionQuotaId: userContext.quotaId, + dataExplorerArea: Constants.Areas.ContextualPane, + useIndexingForSharedThroughput: this.state.enableIndexing, + }; + const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, telemetryData); + + const databaseLevelThroughput: boolean = this.state.createNewDatabase + ? this.state.isSharedThroughputChecked + : this.isSelectedDatabaseSharedThroughput() && !this.state.enableDedicatedThroughput; + + let offerThroughput: number; + let autoPilotMaxThroughput: number; + if (this.state.createNewDatabase) { + if (this.isNewDatabaseAutoscale) { + autoPilotMaxThroughput = this.newDatabaseThroughput; + } else { + offerThroughput = this.newDatabaseThroughput; + } + } else if (!databaseLevelThroughput) { + if (this.isCollectionAutoscale) { + autoPilotMaxThroughput = this.collectionThroughput; + } else { + offerThroughput = this.collectionThroughput; + } + } + + const createCollectionParams: DataModels.CreateCollectionParams = { + createNewDatabase: this.state.createNewDatabase, + collectionId, + databaseId, + databaseLevelThroughput, + offerThroughput, + autoPilotMaxThroughput, + analyticalStorageTtl: this.getAnalyticalStorageTtl(), + indexingPolicy, + partitionKey, + uniqueKeyPolicy, + createMongoWildcardIndex: this.state.createMongoWildCardIndex, + }; + + this.setState({ isExecuting: true }); + + try { + await createCollection(createCollectionParams); + this.setState({ isExecuting: false }); + this.props.explorer.refreshAllDatabases(); + TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey); + this.props.closePanel(); + } catch (error) { + const errorMessage: string = getErrorMessage(error); + this.setState({ isExecuting: false, errorMessage, showErrorDetails: true }); + const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) }; + TelemetryProcessor.traceFailure(Action.CreateCollection, failureTelemetryData, startKey); + } + } +} diff --git a/src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx b/src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx index 5a80c25b0..795d4781a 100644 --- a/src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx +++ b/src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx @@ -133,7 +133,7 @@ describe("Delete Collection Confirmation Pane", () => { .simulate("change", { target: { value: selectedCollectionId } }); expect(wrapper.exists("#sidePanelOkButton")).toBe(true); - wrapper.find("#sidePanelOkButton").hostNodes().simulate("click"); + wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit"); expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId); wrapper.unmount(); @@ -154,7 +154,7 @@ describe("Delete Collection Confirmation Pane", () => { .simulate("change", { target: { value: feedbackText } }); expect(wrapper.exists("#sidePanelOkButton")).toBe(true); - wrapper.find("#sidePanelOkButton").hostNodes().simulate("click"); + wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit"); expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId); const deleteFeedback = new DeleteFeedback( diff --git a/src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx b/src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx index fe1081623..93c39ee12 100644 --- a/src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx +++ b/src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx @@ -1,20 +1,19 @@ -import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"; -import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; -import * as React from "react"; -import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; -import { PanelFooterComponent } from "./PanelFooterComponent"; -import { Collection } from "../../Contracts/ViewModels"; import { Text, TextField } from "office-ui-fabric-react"; -import { userContext } from "../../UserContext"; +import * as React from "react"; import { Areas } from "../../Common/Constants"; import { deleteCollection } from "../../Common/dataAccess/deleteCollection"; -import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; -import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility"; -import { PanelErrorComponent, PanelErrorProps } from "./PanelErrorComponent"; import DeleteFeedback from "../../Common/DeleteFeedback"; +import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; +import { Collection } from "../../Contracts/ViewModels"; +import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility"; +import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import { userContext } from "../../UserContext"; +import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"; import Explorer from "../Explorer"; -import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif"; - +import { PanelFooterComponent } from "./PanelFooterComponent"; +import { PanelInfoErrorComponent, PanelInfoErrorProps } from "./PanelInfoErrorComponent"; +import { PanelLoadingScreen } from "./PanelLoadingScreen"; export interface DeleteCollectionConfirmationPanelProps { explorer: Explorer; closePanel: () => void; @@ -44,8 +43,8 @@ export class DeleteCollectionConfirmationPanel extends React.Component< render(): JSX.Element { return ( -
- +
+
* @@ -79,18 +78,16 @@ export class DeleteCollectionConfirmationPanel extends React.Component<
)}
- this.submit()} /> - -
+ + {this.state.isExecuting && } + ); } - private getPanelErrorProps(): PanelErrorProps { + private getPanelErrorProps(): PanelInfoErrorProps { if (this.state.formError) { return { - isWarning: false, + messageType: "error", message: this.state.formError, showErrorDetails: true, openNotificationConsole: this.props.openNotificationConsole, @@ -98,7 +95,7 @@ export class DeleteCollectionConfirmationPanel extends React.Component< } return { - isWarning: true, + messageType: "warning", showErrorDetails: false, message: "Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.", @@ -109,9 +106,10 @@ export class DeleteCollectionConfirmationPanel extends React.Component< return this.props.explorer.isLastCollection() && !this.props.explorer.isSelectedDatabaseShared(); } - public async submit(): Promise { - const collection = this.props.explorer.findSelectedCollection(); + public async submit(event: React.FormEvent): Promise { + event.preventDefault(); + const collection = this.props.explorer.findSelectedCollection(); if (!collection || this.inputCollectionName !== collection.id()) { const errorMessage = "Input collection name does not match the selected collection"; this.setState({ formError: errorMessage }); diff --git a/src/Explorer/Panes/PanelComponent.less b/src/Explorer/Panes/PanelComponent.less index 820a9f1c9..043816fa1 100644 --- a/src/Explorer/Panes/PanelComponent.less +++ b/src/Explorer/Panes/PanelComponent.less @@ -1,12 +1,58 @@ @import "../../../less/Common/Constants"; -.panelContentContainer { +.panelFormWrapper { display: flex; flex-direction: column; height: 100%; .panelMainContent { flex-grow: 1; + padding: 0 34px; + margin: 20px 0; + overflow: auto; + + & > * { + margin-bottom: @DefaultSpace; + + & > * { + margin-bottom: @SmallSpace; + } + } + + .panelInfoIcon { + font-size: @mediumFontSize; + width: @mediumFontSize; + margin: auto 0 auto @SmallSpace; + color: @InfoIconColor; + cursor: default; + vertical-align: middle; + } + + .panelTextBold { + font-weight: 600; + line-height: 20px; + } + + .panelTextField { + font-size: @mediumFontSize; + border: 1px solid #605e5c; + color: #000; + padding: 4px 10px; + width: @newCollectionPaneInputWidth; + } + + .panelRadioBtn { + margin: 0; + } + + .panelRadioBtnLabel { + font-size: @mediumFontSize; + padding: 0 @LargeSpace 0 @SmallSpace; + } + + .collapsibleSection { + margin-bottom: 0; + } } } @@ -16,26 +62,30 @@ font-weight: 400; } -.panelWarningErrorContainer { +.panelInfoErrorContainer { background-color: @BaseLow; padding: @DefaultSpace; display: inline-flex; - margin-bottom: 24px; + margin: 20px 34px 0 34px; - .panelWarningIcon { + i { font-size: @WarningErrorIconSize; width: @WarningErrorIconSize; - margin: auto 0 auto @SmallSpace; + margin-left: @SmallSpace; + } + + .panelWarningIcon { color: @WarningIconColor; } .panelErrorIcon { - font-size: @WarningErrorIconSize; - width: @WarningErrorIconSize; - margin: auto 0 auto @SmallSpace; color: @ErrorIconColor; } + .panelLargeInfoIcon { + color: @InfoIconColor; + } + .panelWarningErrorDetailsLinkContainer { display: flex; flex-direction: column; @@ -48,10 +98,19 @@ } } -.panelFooter button { - height: 30px; +.panelFooter { + padding: 20px 34px; + border-top: solid 1px #bbbbbb; + + & button { + height: 30px; + } } .deleteCollectionFeedback { margin-top: 12px; } + +.panelGroupSpacing > * { + margin-bottom: @SmallSpace; +} diff --git a/src/Explorer/Panes/PanelContainerComponent.tsx b/src/Explorer/Panes/PanelContainerComponent.tsx index 17d1282b2..008fd2089 100644 --- a/src/Explorer/Panes/PanelContainerComponent.tsx +++ b/src/Explorer/Panes/PanelContainerComponent.tsx @@ -9,10 +9,30 @@ export interface PanelContainerProps { closePanel: () => void; } -export class PanelContainerComponent extends React.Component { +export interface PanelContainerState { + height: string; +} + +export class PanelContainerComponent extends React.Component { private static readonly consoleHeaderHeight = 32; private static readonly consoleContentHeight = 220; + constructor(props: PanelContainerProps) { + super(props); + + this.state = { + height: this.getPanelHeight(), + }; + } + + componentDidMount(): void { + window.addEventListener("resize", () => this.setState({ height: this.getPanelHeight() })); + } + + componentWillUnmount(): void { + window.removeEventListener("resize", () => this.setState({ height: this.getPanelHeight() })); + } + render(): JSX.Element { if (!this.props.panelContent) { return <>; @@ -30,8 +50,10 @@ export class PanelContainerComponent extends React.Component diff --git a/src/Explorer/Panes/PanelErrorComponent.tsx b/src/Explorer/Panes/PanelErrorComponent.tsx deleted file mode 100644 index dcd9e7648..000000000 --- a/src/Explorer/Panes/PanelErrorComponent.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { Icon, Text } from "office-ui-fabric-react"; - -export interface PanelErrorProps { - message: string; - isWarning: boolean; - showErrorDetails: boolean; - openNotificationConsole?: () => void; -} - -export const PanelErrorComponent: React.FunctionComponent = (props: PanelErrorProps): JSX.Element => ( -
- {props.isWarning ? ( - - ) : ( - - )} - - - {props.message} - - {props.showErrorDetails && ( - - More details - - )} - -
-); diff --git a/src/Explorer/Panes/PanelFooterComponent.tsx b/src/Explorer/Panes/PanelFooterComponent.tsx index cbe3adab7..b6c2cb255 100644 --- a/src/Explorer/Panes/PanelFooterComponent.tsx +++ b/src/Explorer/Panes/PanelFooterComponent.tsx @@ -3,13 +3,12 @@ import { PrimaryButton } from "office-ui-fabric-react"; export interface PanelFooterProps { buttonLabel: string; - onOKButtonClicked: () => void; } export const PanelFooterComponent: React.FunctionComponent = ( props: PanelFooterProps ): JSX.Element => (
- props.onOKButtonClicked()} /> +
); diff --git a/src/Explorer/Panes/PanelInfoErrorComponent.tsx b/src/Explorer/Panes/PanelInfoErrorComponent.tsx new file mode 100644 index 000000000..97282b12e --- /dev/null +++ b/src/Explorer/Panes/PanelInfoErrorComponent.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { Icon, Link, Stack, Text } from "office-ui-fabric-react"; + +export interface PanelInfoErrorProps { + message: string; + messageType: string; + showErrorDetails: boolean; + link?: string; + linkText?: string; + openNotificationConsole?: () => void; +} + +export const PanelInfoErrorComponent: React.FunctionComponent = ( + props: PanelInfoErrorProps +): JSX.Element => { + let icon: JSX.Element; + if (props.messageType === "error") { + icon = ; + } else if (props.messageType === "warning") { + icon = ; + } else if (props.messageType === "info") { + icon = ; + } + + return ( + + {icon} + + + {props.message}{" "} + {props.link && props.linkText && ( + + {props.linkText} + + )} + + {props.showErrorDetails && ( + + More details + + )} + + + ); +}; diff --git a/src/Explorer/Panes/PanelLoadingScreen.tsx b/src/Explorer/Panes/PanelLoadingScreen.tsx new file mode 100644 index 000000000..339b3ceb8 --- /dev/null +++ b/src/Explorer/Panes/PanelLoadingScreen.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif"; + +export const PanelLoadingScreen: React.FunctionComponent = () => ( +
+ +
+); diff --git a/src/Explorer/Panes/__snapshots__/DeleteCollectionConfirmationPane.test.tsx.snap b/src/Explorer/Panes/__snapshots__/DeleteCollectionConfirmationPane.test.tsx.snap index cf04bc32d..8166cf4f6 100644 --- a/src/Explorer/Panes/__snapshots__/DeleteCollectionConfirmationPane.test.tsx.snap +++ b/src/Explorer/Panes/__snapshots__/DeleteCollectionConfirmationPane.test.tsx.snap @@ -15,324 +15,334 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect } openNotificationConsole={[Function]} > -
- -
- - + - -  - - - - - +  + + + + - - Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources. - - - -
-
+ + Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources. + + + + +
+ +
@@ -348,7 +358,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect variant="small" > Confirm by typing the collection id @@ -649,18 +659,18 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect validateOnLoad={true} >
Help us improve Azure Cosmos DB! @@ -693,7 +703,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect variant="small" > What is the reason why you are deleting this container? @@ -996,17 +1006,17 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect validateOnLoad={true} >