add collection panel improvements (#630)

Co-authored-by: Jordi Bunster <jbunster@microsoft.com>
This commit is contained in:
victor-meng 2021-04-30 10:23:34 -07:00 committed by GitHub
parent 9878bf0d5e
commit 4efacace16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 351 additions and 307 deletions

View File

@ -29,11 +29,11 @@ export interface DatabaseContextMenuButtonParams {
* New resource tree (in ReactJS) * New resource tree (in ReactJS)
*/ */
export class ResourceTreeContextMenuButtonFactory { export class ResourceTreeContextMenuButtonFactory {
public static createDatabaseContextMenu(container: Explorer): TreeNodeMenuItem[] { public static createDatabaseContextMenu(container: Explorer, databaseId: string): TreeNodeMenuItem[] {
const items: TreeNodeMenuItem[] = [ const items: TreeNodeMenuItem[] = [
{ {
iconSrc: AddCollectionIcon, iconSrc: AddCollectionIcon,
onClick: () => container.onNewCollectionClicked(), onClick: () => container.onNewCollectionClicked(databaseId),
label: container.addCollectionText(), label: container.addCollectionText(),
}, },
]; ];

View File

@ -5,6 +5,7 @@ import { accordionStackTokens } from "../Settings/SettingsRenderUtils";
export interface CollapsibleSectionProps { export interface CollapsibleSectionProps {
title: string; title: string;
isExpandedByDefault: boolean; isExpandedByDefault: boolean;
onExpand?: () => void;
} }
export interface CollapsibleSectionState { export interface CollapsibleSectionState {
@ -23,6 +24,12 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
this.setState({ isExpanded: !this.state.isExpanded }); this.setState({ isExpanded: !this.state.isExpanded });
}; };
public componentDidUpdate(): void {
if (this.state.isExpanded && this.props.onExpand) {
this.props.onExpand();
}
}
public render(): JSX.Element { public render(): JSX.Element {
return ( return (
<> <>

View File

@ -415,7 +415,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
</Text> </Text>
<Text> <Text>
<em> <em>
*This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account
</em> </em>
</Text> </Text>
</Stack> </Stack>
@ -689,7 +689,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
</Text> </Text>
<Text> <Text>
<em> <em>
*This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account
</em> </em>
</Text> </Text>
</Stack> </Stack>

View File

@ -150,7 +150,7 @@ exports[`SettingsUtils functions render 1`] = `
</Text> </Text>
<Text> <Text>
<em> <em>
*This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account
</em> </em>
</Text> </Text>
</Stack> </Stack>

View File

@ -11,10 +11,6 @@
padding: 0 @LargeSpace 0 @SmallSpace; padding: 0 @LargeSpace 0 @SmallSpace;
} }
.throughputInputSpacing { .throughputInputSpacing > :not(:last-child) {
margin-bottom: @SmallSpace; margin-bottom: @DefaultSpace;
& > * {
margin-bottom: @SmallSpace;
}
} }

View File

@ -3,11 +3,13 @@ import React from "react";
import * as Constants from "../../../Common/Constants"; import * as Constants from "../../../Common/Constants";
import * as SharedConstants from "../../../Shared/Constants"; import * as SharedConstants from "../../../Shared/Constants";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import { getCollectionName } from "../../../Utils/APITypeUtils";
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils"; import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
import * as PricingUtils from "../../../Utils/PricingUtils"; import * as PricingUtils from "../../../Utils/PricingUtils";
export interface ThroughputInputProps { export interface ThroughputInputProps {
isDatabase: boolean; isDatabase: boolean;
isSharded: boolean;
showFreeTierExceedThroughputTooltip: boolean; showFreeTierExceedThroughputTooltip: boolean;
setThroughputValue: (throughput: number) => void; setThroughputValue: (throughput: number) => void;
setIsAutoscale: (isAutoscale: boolean) => void; setIsAutoscale: (isAutoscale: boolean) => void;
@ -18,6 +20,7 @@ export interface ThroughputInputState {
isAutoscaleSelected: boolean; isAutoscaleSelected: boolean;
throughput: number; throughput: number;
isCostAcknowledged: boolean; isCostAcknowledged: boolean;
throughputError: string;
} }
export class ThroughputInput extends React.Component<ThroughputInputProps, ThroughputInputState> { export class ThroughputInput extends React.Component<ThroughputInputProps, ThroughputInputState> {
@ -28,6 +31,7 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
isAutoscaleSelected: true, isAutoscaleSelected: true,
throughput: AutoPilotUtils.minAutoPilotThroughput, throughput: AutoPilotUtils.minAutoPilotThroughput,
isCostAcknowledged: false, isCostAcknowledged: false,
throughputError: undefined,
}; };
this.props.setThroughputValue(AutoPilotUtils.minAutoPilotThroughput); this.props.setThroughputValue(AutoPilotUtils.minAutoPilotThroughput);
@ -39,11 +43,11 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
<div className="throughputInputContainer throughputInputSpacing"> <div className="throughputInputContainer throughputInputSpacing">
<Stack horizontal> <Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span> <span className="mandatoryStar">*&nbsp;</span>
<Text variant="small" style={{ lineHeight: "20px" }}> <Text variant="small" style={{ lineHeight: "20px", fontWeight: 600 }}>
{this.getThroughputLabelText()} {this.getThroughputLabelText()}
</Text> </Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={PricingUtils.getRuToolTipText()}> <TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={PricingUtils.getRuToolTipText()}>
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
@ -74,7 +78,7 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
{this.state.isAutoscaleSelected && ( {this.state.isAutoscaleSelected && (
<Stack className="throughputInputSpacing"> <Stack className="throughputInputSpacing">
<Text variant="small"> <Text variant="small">
Provision maximum RU/s required by this resource. Estimate your required RU/s with&nbsp; Estimate your required RU/s with&nbsp;
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/"> <Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/">
capacity calculator capacity calculator
</Link> </Link>
@ -82,11 +86,11 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
</Text> </Text>
<Stack horizontal> <Stack horizontal>
<Text variant="small" style={{ lineHeight: "20px" }}> <Text variant="small" style={{ lineHeight: "20px", fontWeight: 600 }}>
Max RU/s {this.props.isDatabase ? "Database" : getCollectionName()} max RU/s
</Text> </Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={this.getAutoScaleTooltip()}> <TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={this.getAutoScaleTooltip()}>
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
@ -101,11 +105,12 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
min={AutoPilotUtils.minAutoPilotThroughput} min={AutoPilotUtils.minAutoPilotThroughput}
value={this.state.throughput.toString()} value={this.state.throughput.toString()}
aria-label="Max request units per second" aria-label="Max request units per second"
required={true} errorMessage={this.state.throughputError}
/> />
<Text variant="small"> <Text variant="small">
Your {this.props.isDatabase ? "database" : "container"} throughput will automatically scale from{" "} Your {this.props.isDatabase ? "database" : getCollectionName().toLocaleLowerCase()} throughput will
automatically scale from{" "}
<b> <b>
{AutoPilotUtils.getMinRUsBasedOnUserInput(this.state.throughput)} RU/s (10% of max RU/s) -{" "} {AutoPilotUtils.getMinRUsBasedOnUserInput(this.state.throughput)} RU/s (10% of max RU/s) -{" "}
{this.state.throughput} RU/s {this.state.throughput} RU/s
@ -147,6 +152,7 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
value={this.state.throughput.toString()} value={this.state.throughput.toString()}
aria-label="Max request units per second" aria-label="Max request units per second"
required={true} required={true}
errorMessage={this.state.throughputError}
/> />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
@ -156,6 +162,7 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
{this.state.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && ( {this.state.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && (
<Stack horizontal verticalAlign="start"> <Stack horizontal verticalAlign="start">
<span className="mandatoryStar">*&nbsp;</span>
<Checkbox <Checkbox
checked={this.state.isCostAcknowledged} checked={this.state.isCostAcknowledged}
styles={{ styles={{
@ -177,30 +184,35 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
} }
private getThroughputLabelText(): string { private getThroughputLabelText(): string {
let throughputHeaderText: string;
if (this.state.isAutoscaleSelected) { if (this.state.isAutoscaleSelected) {
return AutoPilotUtils.getAutoPilotHeaderText(); throughputHeaderText = AutoPilotUtils.getAutoPilotHeaderText().toLocaleLowerCase();
} else {
const minRU: string = SharedConstants.CollectionCreation.DefaultCollectionRUs400.toLocaleString();
const maxRU: string = userContext.isTryCosmosDBSubscription
? Constants.TryCosmosExperience.maxRU.toLocaleString()
: "unlimited";
throughputHeaderText = `throughput (${minRU} - ${maxRU} RU/s)`;
} }
const minRU: string = SharedConstants.CollectionCreation.DefaultCollectionRUs400.toLocaleString(); return `${this.props.isDatabase ? "Database" : getCollectionName()} ${throughputHeaderText}`;
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 { private onThroughputValueChange(newInput: string): void {
const newThroughput = parseInt(newInput); const newThroughput = parseInt(newInput);
this.setState({ throughput: newThroughput }); this.setState({ throughput: newThroughput });
this.props.setThroughputValue(newThroughput); this.props.setThroughputValue(newThroughput);
if (!this.props.isSharded && newThroughput > 10000) {
this.setState({ throughputError: "Unsharded collections support up to 10,000 RUs" });
} else {
this.setState({ throughputError: undefined });
}
} }
private getAutoScaleTooltip(): string { private getAutoScaleTooltip(): string {
return `After the first ${AutoPilotUtils.getStorageBasedOnUserInput( const collectionName = getCollectionName().toLocaleLowerCase();
this.state.throughput return `Set the max RU/s to the highest RU/s you want your ${collectionName} to scale to. The ${collectionName} will scale between 10% of max RU/s to the max RU/s based on usage.`;
)} GB of data stored, the max
RU/s will be automatically upgraded based on the new storage value.`;
} }
private getCostAcknowledgeText(): string { private getCostAcknowledgeText(): string {
@ -271,10 +283,20 @@ const CostEstimateText: React.FunctionComponent<CostEstimateTextProps> = (props:
? PricingUtils.getAutoscalePricePerRu(serverId, multiplier) * multiplier ? PricingUtils.getAutoscalePricePerRu(serverId, multiplier) * multiplier
: PricingUtils.getPricePerRu(serverId) * multiplier; : PricingUtils.getPricePerRu(serverId) * multiplier;
const iconWithEstimatedCostDisclaimer: JSX.Element = (
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={PricingUtils.estimatedCostDisclaimer}
styles={{ root: { verticalAlign: "bottom" } }}
>
<Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost>
);
if (isAutoscale) { if (isAutoscale) {
return ( return (
<Text variant="small"> <Text variant="small">
Estimated monthly cost ({currency}):{" "} Estimated monthly cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
<b> <b>
{currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice / 10)} -{" "} {currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice / 10)} -{" "}
{currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)}{" "} {currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)}{" "}
@ -287,7 +309,7 @@ const CostEstimateText: React.FunctionComponent<CostEstimateTextProps> = (props:
return ( return (
<Text variant="small"> <Text variant="small">
Cost ({currency}):{" "} Estimated cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
<b> <b>
{currencySign + PricingUtils.calculateEstimateNumber(hourlyPrice)} hourly /{" "} {currencySign + PricingUtils.calculateEstimateNumber(hourlyPrice)} hourly /{" "}
{currencySign + PricingUtils.calculateEstimateNumber(dailyPrice)} daily /{" "} {currencySign + PricingUtils.calculateEstimateNumber(dailyPrice)} daily /{" "}
@ -295,8 +317,6 @@ const CostEstimateText: React.FunctionComponent<CostEstimateTextProps> = (props:
</b> </b>
({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits}RU/s,{" "} ({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits}RU/s,{" "}
{currencySign + pricePerRu}/RU) {currencySign + pricePerRu}/RU)
<br />
<em>{PricingUtils.estimatedCostDisclaimer}</em>
</Text> </Text>
); );
}; };

View File

@ -30,12 +30,12 @@ import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
import { ArcadiaResourceManager } from "../SparkClusterManager/ArcadiaResourceManager"; import { ArcadiaResourceManager } from "../SparkClusterManager/ArcadiaResourceManager";
import { userContext } from "../UserContext"; import { userContext } from "../UserContext";
import { getCollectionName } from "../Utils/APITypeUtils";
import { decryptJWTToken, getAuthorizationHeader } from "../Utils/AuthorizationUtils"; import { decryptJWTToken, getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { stringToBlob } from "../Utils/BlobUtils"; import { stringToBlob } from "../Utils/BlobUtils";
import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils"; import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils"; import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils";
import * as PricingUtils from "../Utils/PricingUtils";
import * as ComponentRegisterer from "./ComponentRegisterer"; import * as ComponentRegisterer from "./ComponentRegisterer";
import { ArcadiaWorkspaceItem } from "./Controls/Arcadia/ArcadiaMenuPicker"; import { ArcadiaWorkspaceItem } from "./Controls/Arcadia/ArcadiaMenuPicker";
import { CommandButtonComponentProps } from "./Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "./Controls/CommandButton/CommandButtonComponent";
@ -1950,14 +1950,14 @@ export default class Explorer {
} }
} }
public onNewCollectionClicked(): void { public onNewCollectionClicked(databaseId?: string): void {
if (userContext.apiType === "Cassandra") { if (userContext.apiType === "Cassandra") {
this.cassandraAddCollectionPane.open(); this.cassandraAddCollectionPane.open();
} else if (userContext.features.enableReactPane) { } else if (userContext.features.enableKOPanel) {
this.openAddCollectionPanel();
} else {
this.addCollectionPane.open(this.selectedDatabaseId()); this.addCollectionPane.open(this.selectedDatabaseId());
document.getElementById("linkAddCollection").focus(); document.getElementById("linkAddCollection").focus();
} else {
this.openAddCollectionPanel(databaseId);
} }
} }
@ -2061,14 +2061,9 @@ export default class Explorer {
} }
public openDeleteCollectionConfirmationPane(): void { public openDeleteCollectionConfirmationPane(): void {
let collectionName = PricingUtils.getCollectionName(userContext.apiType);
this.openSidePanel( this.openSidePanel(
"Delete " + collectionName, "Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane <DeleteCollectionConfirmationPane explorer={this} closePanel={this.closeSidePanel} />
explorer={this}
collectionName={collectionName}
closePanel={this.closeSidePanel}
/>
); );
} }
@ -2106,14 +2101,15 @@ export default class Explorer {
); );
} }
public async openAddCollectionPanel(): Promise<void> { public async openAddCollectionPanel(databaseId?: string): Promise<void> {
await this.loadDatabaseOffers(); await this.loadDatabaseOffers();
this.openSidePanel( this.openSidePanel(
"New Collection", "New " + getCollectionName(),
<AddCollectionPanel <AddCollectionPanel
explorer={this} explorer={this}
closePanel={() => this.closeSidePanel()} closePanel={() => this.closeSidePanel()}
openNotificationConsole={() => this.expandConsole()} openNotificationConsole={() => this.expandConsole()}
databaseId={databaseId}
/> />
); );
} }

View File

@ -3,7 +3,6 @@ import { ActionContracts } from "../Contracts/ExplorerContracts";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import Explorer from "./Explorer"; import Explorer from "./Explorer";
import { handleOpenAction } from "./OpenActions"; import { handleOpenAction } from "./OpenActions";
import AddCollectionPane from "./Panes/AddCollectionPane";
import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane"; import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane";
describe("OpenActions", () => { describe("OpenActions", () => {
@ -15,8 +14,7 @@ describe("OpenActions", () => {
beforeEach(() => { beforeEach(() => {
explorer = {} as Explorer; explorer = {} as Explorer;
explorer.addCollectionPane = {} as AddCollectionPane; explorer.onNewCollectionClicked = jest.fn();
explorer.addCollectionPane.open = jest.fn();
explorer.cassandraAddCollectionPane = {} as CassandraAddCollectionPane; explorer.cassandraAddCollectionPane = {} as CassandraAddCollectionPane;
explorer.cassandraAddCollectionPane.open = jest.fn(); explorer.cassandraAddCollectionPane.open = jest.fn();
explorer.closeAllPanes = () => {}; explorer.closeAllPanes = () => {};
@ -90,24 +88,24 @@ describe("OpenActions", () => {
}); });
describe("AddCollection pane kind", () => { describe("AddCollection pane kind", () => {
it("string value should call addCollectionPane.open", () => { it("string value should call explorer.onNewCollectionClicked", () => {
const action = { const action = {
actionType: "OpenPane", actionType: "OpenPane",
paneKind: "AddCollection", paneKind: "AddCollection",
}; };
const actionHandled = handleOpenAction(action, [], explorer); const actionHandled = handleOpenAction(action, [], explorer);
expect(explorer.addCollectionPane.open).toHaveBeenCalled(); expect(explorer.onNewCollectionClicked).toHaveBeenCalled();
}); });
it("enum value should call addCollectionPane.open", () => { it("enum value should call explorer.onNewCollectionClicked", () => {
const action = { const action = {
actionType: "OpenPane", actionType: "OpenPane",
paneKind: ActionContracts.PaneKind.AddCollection, paneKind: ActionContracts.PaneKind.AddCollection,
}; };
const actionHandled = handleOpenAction(action, [], explorer); const actionHandled = handleOpenAction(action, [], explorer);
expect(explorer.addCollectionPane.open).toHaveBeenCalled(); expect(explorer.onNewCollectionClicked).toHaveBeenCalled();
}); });
}); });
}); });

View File

@ -141,7 +141,7 @@ function openPane(action: ActionContracts.OpenPane, explorer: Explorer) {
(<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.AddCollection] (<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.AddCollection]
) { ) {
explorer.closeAllPanes(); explorer.closeAllPanes();
explorer.addCollectionPane.open(); explorer.onNewCollectionClicked();
} else if ( } else if (
action.paneKind === ActionContracts.PaneKind.CassandraAddCollection || action.paneKind === ActionContracts.PaneKind.CassandraAddCollection ||
(<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.CassandraAddCollection] (<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.CassandraAddCollection]

View File

@ -143,7 +143,6 @@
size="40" size="40"
class="collid" class="collid"
data-bind="visible: databaseCreateNew, textInput: databaseId, hasFocus: firstFieldHasFocus" data-bind="visible: databaseCreateNew, textInput: databaseId, hasFocus: firstFieldHasFocus"
aria-label="Database id"
autofocus autofocus
/> />
@ -161,7 +160,6 @@
size="40" size="40"
class="collid" class="collid"
data-bind="visible: !databaseCreateNew(), textInput: databaseId, hasFocus: firstFieldHasFocus" data-bind="visible: !databaseCreateNew(), textInput: databaseId, hasFocus: firstFieldHasFocus"
aria-label="Database id"
/> />
<datalist id="databasesList" data-bind="foreach: databaseIds" data-bind="visible: databaseCreateNew"> <datalist id="databasesList" data-bind="foreach: databaseIds" data-bind="visible: databaseCreateNew">
@ -246,7 +244,7 @@
placeholder="e.g., Container1" placeholder="e.g., Container1"
size="40" size="40"
class="textfontclr collid" class="textfontclr collid"
data-bind="value: collectionId, attr: { 'aria-label': collectionIdTitle }" data-bind="value: collectionId"
/> />
</div> </div>
@ -352,7 +350,6 @@
attr: { attr: {
placeholder: partitionKeyPlaceholder, placeholder: partitionKeyPlaceholder,
required: partitionKeyVisible(), required: partitionKeyVisible(),
'aria-label': partitionKeyName,
pattern: partitionKeyPattern, pattern: partitionKeyPattern,
title: partitionKeyTitle title: partitionKeyTitle
}" }"

View File

@ -476,7 +476,6 @@ export default class AddCollectionPane extends ContextualPaneBase {
userContext.portalEnv, userContext.portalEnv,
this.isFreeTierAccount(), this.isFreeTierAccount(),
this.container.isFirstResourceCreated(), this.container.isFirstResourceCreated(),
userContext.apiType,
true true
); );
}); });

View File

@ -8,6 +8,7 @@ import {
IconButton, IconButton,
IDropdownOption, IDropdownOption,
Link, Link,
Separator,
Stack, Stack,
Text, Text,
TooltipHost, TooltipHost,
@ -23,6 +24,7 @@ import { CollectionCreation, IndexingPolicies } from "../../Shared/Constants";
import { Action } from "../../Shared/Telemetry/TelemetryConstants"; import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { getCollectionName } from "../../Utils/APITypeUtils";
import { getUpsellMessage } from "../../Utils/PricingUtils"; import { getUpsellMessage } from "../../Utils/PricingUtils";
import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent"; import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput"; import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput";
@ -35,6 +37,7 @@ export interface AddCollectionPanelProps {
explorer: Explorer; explorer: Explorer;
closePanel: () => void; closePanel: () => void;
openNotificationConsole: () => void; openNotificationConsole: () => void;
databaseId?: string;
} }
export interface AddCollectionPanelState { export interface AddCollectionPanelState {
@ -48,7 +51,7 @@ export interface AddCollectionPanelState {
partitionKey: string; partitionKey: string;
enableDedicatedThroughput: boolean; enableDedicatedThroughput: boolean;
createMongoWildCardIndex: boolean; createMongoWildCardIndex: boolean;
useHashV1: boolean; useHashV2: boolean;
enableAnalyticalStore: boolean; enableAnalyticalStore: boolean;
uniqueKeys: string[]; uniqueKeys: string[];
errorMessage: string; errorMessage: string;
@ -67,17 +70,18 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
super(props); super(props);
this.state = { this.state = {
createNewDatabase: userContext.apiType !== "Tables", createNewDatabase: userContext.apiType !== "Tables" && !this.props.databaseId,
newDatabaseId: "", newDatabaseId: "",
isSharedThroughputChecked: this.getSharedThroughputDefault(), isSharedThroughputChecked: this.getSharedThroughputDefault(),
selectedDatabaseId: userContext.apiType === "Tables" ? CollectionCreation.TablesAPIDefaultDatabase : undefined, selectedDatabaseId:
userContext.apiType === "Tables" ? CollectionCreation.TablesAPIDefaultDatabase : this.props.databaseId,
collectionId: "", collectionId: "",
enableIndexing: true, enableIndexing: true,
isSharded: userContext.apiType !== "Tables", isSharded: userContext.apiType !== "Tables",
partitionKey: "", partitionKey: "",
enableDedicatedThroughput: false, enableDedicatedThroughput: false,
createMongoWildCardIndex: true, createMongoWildCardIndex: true,
useHashV1: false, useHashV2: false,
enableAnalyticalStore: false, enableAnalyticalStore: false,
uniqueKeys: [], uniqueKeys: [],
errorMessage: "", errorMessage: "",
@ -100,13 +104,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!this.state.errorMessage && this.isFreeTierAccount() && ( {!this.state.errorMessage && this.isFreeTierAccount() && (
<PanelInfoErrorComponent <PanelInfoErrorComponent
message={getUpsellMessage( message={getUpsellMessage(userContext.portalEnv, true, this.props.explorer.isFirstResourceCreated(), true)}
userContext.portalEnv,
true,
this.props.explorer.isFirstResourceCreated(),
userContext.apiType,
true
)}
messageType="info" messageType="info"
showErrorDetails={false} showErrorDetails={false}
openNotificationConsole={this.props.openNotificationConsole} openNotificationConsole={this.props.openNotificationConsole}
@ -120,13 +118,15 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<Stack horizontal> <Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span> <span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small"> <Text className="panelTextBold" variant="small">
Database id Database {userContext.apiType === "Mongo" ? "name" : "id"}
</Text> </Text>
<TooltipHost <TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge} directionalHint={DirectionalHint.bottomLeftEdge}
content="A database is analogous to a namespace. It is the unit of management for a set of containers." content={`A database is analogous to a namespace. It is the unit of management for a set of ${getCollectionName(
true
).toLocaleLowerCase()}.`}
> >
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
@ -140,7 +140,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
type="radio" type="radio"
role="radio" role="radio"
id="databaseCreateNew" id="databaseCreateNew"
data-test="addCollection-createNewDatabase"
tabIndex={0} tabIndex={0}
onChange={this.onCreateNewDatabaseRadioBtnChange.bind(this)} onChange={this.onCreateNewDatabaseRadioBtnChange.bind(this)}
/> />
@ -154,8 +153,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
name="databaseType" name="databaseType"
type="radio" type="radio"
role="radio" role="radio"
id="databaseUseExisting"
data-test="addCollection-existingDatabase"
tabIndex={0} tabIndex={0}
onChange={this.onUseExistingDatabaseRadioBtnChange.bind(this)} onChange={this.onUseExistingDatabaseRadioBtnChange.bind(this)}
/> />
@ -166,8 +163,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<Stack className="panelGroupSpacing"> <Stack className="panelGroupSpacing">
<input <input
name="newDatabaseId" name="newDatabaseId"
id="databaseId" id="newDatabaseId"
data-test="addCollection-newDatabaseId"
aria-required aria-required
required required
type="text" type="text"
@ -177,7 +173,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
placeholder="Type a new database id" placeholder="Type a new database id"
size={40} size={40}
className="panelTextField" className="panelTextField"
aria-label="Database id" aria-label="New database id"
autoFocus autoFocus
value={this.state.newDatabaseId} value={this.state.newDatabaseId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
@ -188,7 +184,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!this.isServerlessAccount() && ( {!this.isServerlessAccount() && (
<Stack horizontal> <Stack horizontal>
<Checkbox <Checkbox
label="Provision database throughput" label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`}
checked={this.state.isSharedThroughputChecked} checked={this.state.isSharedThroughputChecked}
styles={{ styles={{
text: { fontSize: 12 }, text: { fontSize: 12 },
@ -201,9 +197,11 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
/> />
<TooltipHost <TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge} directionalHint={DirectionalHint.bottomLeftEdge}
content="Provisioned throughput at the database level will be shared across all containers within the database." content={`Throughput configured at the database level will be shared across all ${getCollectionName(
true
).toLocaleLowerCase()} within the database.`}
> >
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
)} )}
@ -214,6 +212,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
this.isFreeTierAccount() && !this.props.explorer.isFirstResourceCreated() this.isFreeTierAccount() && !this.props.explorer.isFirstResourceCreated()
} }
isDatabase={true} isDatabase={true}
isSharded={this.state.isSharded}
setThroughputValue={(throughput: number) => (this.newDatabaseThroughput = throughput)} setThroughputValue={(throughput: number) => (this.newDatabaseThroughput = throughput)}
setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)} setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)}
onCostAcknowledgeChange={(isAcknowledge: boolean) => (this.isCostAcknowledged = isAcknowledge)} onCostAcknowledgeChange={(isAcknowledge: boolean) => (this.isCostAcknowledged = isAcknowledge)}
@ -230,38 +229,40 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
onChange={(event: React.FormEvent<HTMLDivElement>, database: IDropdownOption) => onChange={(event: React.FormEvent<HTMLDivElement>, database: IDropdownOption) =>
this.setState({ selectedDatabaseId: database.key as string }) this.setState({ selectedDatabaseId: database.key as string })
} }
defaultSelectedKey={this.props.databaseId}
responsiveMode={999}
/> />
)} )}
<Separator className="panelSeparator" />
</Stack> </Stack>
<Stack> <Stack>
<Stack horizontal> <Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span> <span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small"> <Text className="panelTextBold" variant="small">
{`${this.getCollectionName()} id`} {`${getCollectionName()} ${userContext.apiType === "Mongo" ? "name" : "id"}`}
</Text> </Text>
<TooltipHost <TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge} directionalHint={DirectionalHint.bottomLeftEdge}
content="Unique identifier for the container and used for id-based routing through REST and all SDKs." content={`Unique identifier for the ${getCollectionName().toLocaleLowerCase()} and used for id-based routing through REST and all SDKs.`}
> >
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
<input <input
name="collectionId" name="collectionId"
id="containerId" id="collectionId"
data-test="addCollection-collectionId"
type="text" type="text"
aria-required aria-required
required required
autoComplete="off" autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]" pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'" title="May not end with space nor contain characters '\' '/' '#' '?'"
placeholder={`e.g., ${this.getCollectionName()}1`} placeholder={`e.g., ${getCollectionName()}1`}
size={40} size={40}
className="panelTextField" className="panelTextField"
aria-label={`${this.getCollectionName()} id`} aria-label={`${getCollectionName()} id`}
value={this.state.collectionId} value={this.state.collectionId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
this.setState({ collectionId: event.target.value }) this.setState({ collectionId: event.target.value })
@ -320,13 +321,15 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
<Stack horizontal> <Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span> <span className="mandatoryStar">*&nbsp;</span>
<Text className="panelTextBold" variant="small"> <Text className="panelTextBold" variant="small">
Sharding options Sharding
</Text> </Text>
<TooltipHost <TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge} directionalHint={DirectionalHint.bottomLeftEdge}
content="Unique identifier for the container and used for id-based routing through REST and all SDKs." content={
"Sharded collections split your data across many replica sets (shards) to achieve unlimited scalability. Sharded collections require choosing a shard key (field) to evenly distribute your data."
}
> >
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
@ -371,18 +374,15 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Text> </Text>
<TooltipHost <TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge} directionalHint={DirectionalHint.bottomLeftEdge}
content={`The ${this.getPartitionKeyName()} is used to automatically partition data among content={this.getPartitionKeyTooltipText()}
multiple servers for scalability. Choose a JSON property name that has a wide range of values and is
likely to have evenly distributed access patterns.`}
> >
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
<input <input
type="text" type="text"
id="addCollection-partitionKeyValue" id="addCollection-partitionKeyValue"
data-test="addCollection-partitionKeyValue"
aria-required aria-required
required required
size={40} size={40}
@ -392,9 +392,17 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
pattern={userContext.apiType === "Gremlin" ? "^/[^/]*" : ".*"} pattern={userContext.apiType === "Gremlin" ? "^/[^/]*" : ".*"}
title={userContext.apiType === "Gremlin" ? "May not use composite partition key" : ""} title={userContext.apiType === "Gremlin" ? "May not use composite partition key" : ""}
value={this.state.partitionKey} value={this.state.partitionKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ partitionKey: event.target.value }) if (
} userContext.apiType !== "Mongo" &&
this.state.partitionKey === "" &&
!event.target.value.startsWith("/")
) {
this.setState({ partitionKey: "/" + event.target.value });
} else {
this.setState({ partitionKey: event.target.value });
}
}}
/> />
</Stack> </Stack>
)} )}
@ -402,7 +410,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!this.isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && ( {!this.isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && (
<Stack horizontal verticalAlign="center"> <Stack horizontal verticalAlign="center">
<Checkbox <Checkbox
label={`Provision dedicated throughput for this ${this.getCollectionName()}`} label={`Provision dedicated throughput for this ${getCollectionName().toLocaleLowerCase()}`}
checked={this.state.enableDedicatedThroughput} checked={this.state.enableDedicatedThroughput}
styles={{ styles={{
text: { fontSize: 12 }, text: { fontSize: 12 },
@ -415,12 +423,14 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
/> />
<TooltipHost <TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge} directionalHint={DirectionalHint.bottomLeftEdge}
content="You can optionally provision dedicated throughput for a container within a database that has throughput content={`You can optionally provision dedicated throughput for a ${getCollectionName().toLocaleLowerCase()} within a database that has throughput
provisioned. This dedicated throughput amount will not be shared with other containers in the database and provisioned. This dedicated throughput amount will not be shared with other ${getCollectionName(
true
).toLocaleLowerCase()} in the database and
does not count towards the throughput you provisioned for the database. This throughput amount will be does not count towards the throughput you provisioned for the database. This throughput amount will be
billed in addition to the throughput amount you provisioned at the database level." billed in addition to the throughput amount you provisioned at the database level.`}
> >
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
)} )}
@ -431,6 +441,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
this.isFreeTierAccount() && !this.props.explorer.isFirstResourceCreated() this.isFreeTierAccount() && !this.props.explorer.isFirstResourceCreated()
} }
isDatabase={false} isDatabase={false}
isSharded={this.state.isSharded}
setThroughputValue={(throughput: number) => (this.collectionThroughput = throughput)} setThroughputValue={(throughput: number) => (this.collectionThroughput = throughput)}
setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)} setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)}
onCostAcknowledgeChange={(isAcknowledged: boolean) => { onCostAcknowledgeChange={(isAcknowledged: boolean) => {
@ -451,7 +462,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
creating a unique key policy when a container is created, you ensure the uniqueness of one or more values creating a unique key policy when a container is created, you ensure the uniqueness of one or more values
per partition key." per partition key."
> >
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost> </TooltipHost>
</Stack> </Stack>
@ -504,134 +515,129 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Stack> </Stack>
)} )}
<CollapsibleSectionComponent title="Advanced" isExpandedByDefault={false}> {userContext.apiType !== "Tables" && (
<Stack className="panelGroupSpacing"> <CollapsibleSectionComponent
{this.props.explorer.isEnableMongoCapabilityPresent() && ( title="Advanced"
<Stack> isExpandedByDefault={false}
<Stack horizontal> onExpand={() => {
<span className="mandatoryStar">*&nbsp;</span> TelemetryProcessor.traceOpen(Action.ExpandAddCollectionPaneAdvancedSection);
<Text className="panelTextBold" variant="small"> this.scrollToAdvancedSection();
Indexing }}
</Text> >
<TooltipHost <Stack className="panelGroupSpacing" id="collapsibleSectionContent">
directionalHint={DirectionalHint.bottomLeftEdge} {this.props.explorer.isEnableMongoCapabilityPresent() && (
content="By default, only the field _id is indexed. Creating a wildcard index on all fields will quickly optimize <Stack className="panelGroupSpacing">
query performance and is recommended during development." <Stack horizontal>
> <span className="mandatoryStar">*&nbsp;</span>
<Icon iconName="InfoSolid" className="panelInfoIcon" /> <Text className="panelTextBold" variant="small">
</TooltipHost> Indexing
</Stack> </Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content="The _id field is indexed by default. Creating a wildcard index for all fields will optimize queries and is recommended for development."
>
<Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost>
</Stack>
<Checkbox
label="Create a Wildcard Index on all fields"
checked={this.state.createMongoWildCardIndex}
styles={{
text: { fontSize: 12 },
checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) =>
this.setState({ createMongoWildCardIndex: isChecked })
}
/>
</Stack>
)}
{userContext.apiType === "SQL" && (
<Checkbox <Checkbox
label="Create a Wildcard Index on all fields" label="My partition key is larger than 100 bytes"
checked={this.state.createMongoWildCardIndex} checked={this.state.useHashV2}
styles={{ styles={{
text: { fontSize: 12 }, text: { fontSize: 12 },
checkbox: { width: 12, height: 12 }, checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center" }, label: { padding: 0, alignItems: "center" },
}} }}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) => onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) =>
this.setState({ createMongoWildCardIndex: isChecked }) this.setState({ useHashV2: isChecked })
} }
/> />
</Stack> )}
)}
{userContext.apiType === "SQL" && ( {this.shouldShowAnalyticalStoreOptions() && (
<Stack className="panelGroupSpacing"> <Stack className="panelGroupSpacing">
<Stack horizontal verticalAlign="start"> <Stack horizontal>
<Checkbox <Text className="panelTextBold" variant="small">
checked={this.state.useHashV1} Analytical store
styles={{
checkbox: { width: 12, height: 12 },
label: { padding: 0, margin: "4px 4px 0 0" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) =>
this.setState({ useHashV1: isChecked })
}
/>
<Text variant="small" style={{ lineHeight: "20px" }}>
My application uses an older Cosmos .NET or Java SDK version (.NET V1 or Java V2)
</Text>
</Stack>
<Text variant="small">
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.{" "}
<Link target="_blank" href="https://aka.ms/cosmosdb/pkv2">
Learn more
</Link>
</Text>
</Stack>
)}
{this.shouldShowAnalyticalStoreOptions() && (
<Stack className="panelGroupSpacing">
<Stack horizontal>
<Text className="panelTextBold" variant="small">
Analytical store
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content="Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads. Learn more"
>
<Icon iconName="InfoSolid" className="panelInfoIcon" />
</TooltipHost>
</Stack>
<Stack horizontal verticalAlign="center">
<input
className="panelRadioBtn"
checked={this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Enable analytical store"
aria-checked={this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="enableAnalyticalStoreBtn"
tabIndex={0}
onChange={this.onEnableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">On</span>
<input
className="panelRadioBtn"
checked={!this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Disable analytical store"
aria-checked={!this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="disableAnalyticalStoreBtn"
tabIndex={0}
onChange={this.onDisableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Off</span>
</Stack>
{!this.isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small">
Azure Synapse Link is required for creating an analytical store container. Enable Synapse Link
for this Cosmos DB account.{" "}
<Link href="https://aka.ms/cosmosdb-synapselink" target="_blank">
Learn more
</Link>
</Text> </Text>
<DefaultButton <TooltipHost
text="Enable" directionalHint={DirectionalHint.bottomLeftEdge}
onClick={() => this.props.explorer.openEnableSynapseLinkDialog()} content={this.getAnalyticalStorageTooltipContent()}
style={{ height: 27, width: 80 }} >
styles={{ label: { fontSize: 12 } }} <Icon iconName="Info" className="panelInfoIcon" />
/> </TooltipHost>
</Stack> </Stack>
)}
</Stack> <Stack horizontal verticalAlign="center">
)} <input
</Stack> className="panelRadioBtn"
</CollapsibleSectionComponent> checked={this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Enable analytical store"
aria-checked={this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="enableAnalyticalStoreBtn"
tabIndex={0}
onChange={this.onEnableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">On</span>
<input
className="panelRadioBtn"
checked={!this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Disable analytical store"
aria-checked={!this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="disableAnalyticalStoreBtn"
tabIndex={0}
onChange={this.onDisableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Off</span>
</Stack>
{!this.isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small">
Azure Synapse Link is required for creating an analytical store{" "}
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account.{" "}
<Link href="https://aka.ms/cosmosdb-synapselink" target="_blank">
Learn more
</Link>
</Text>
<DefaultButton
text="Enable"
onClick={() => this.props.explorer.openEnableSynapseLinkDialog()}
style={{ height: 27, width: 80 }}
styles={{ label: { fontSize: 12 } }}
/>
</Stack>
)}
</Stack>
)}
</Stack>
</CollapsibleSectionComponent>
)}
</div> </div>
<PanelFooterComponent buttonLabel="OK" /> <PanelFooterComponent buttonLabel="OK" />
@ -648,24 +654,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
})); }));
} }
private getCollectionName(): string { private getPartitionKeyName(isLowerCase?: boolean): string {
switch (userContext.apiType) { const partitionKeyName = userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
case "SQL":
return "Container";
case "Mongo":
return "Collection";
case "Cassandra":
case "Tables":
return "Table";
case "Gremlin":
return "Graph";
default:
throw new Error(`Unsupported default experience type: ${userContext.apiType}`);
}
}
private getPartitionKeyName(): string { return isLowerCase ? partitionKeyName.toLocaleLowerCase() : partitionKeyName;
return userContext.apiType === "Mongo" ? "Shard key" : "Partition key";
} }
private getPartitionKeyPlaceHolder(): string { private getPartitionKeyPlaceHolder(): string {
@ -774,6 +766,34 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
: "Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations."; : "Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations.";
} }
private getPartitionKeyTooltipText(): string {
if (userContext.apiType === "Mongo") {
return "The shard key (field) is used to split your data across many replica sets (shards) to achieve unlimited scalability. Its critical to choose a field that will evenly distribute your data.";
}
let tooltipText = `The ${this.getPartitionKeyName(
true
)} is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.`;
if (userContext.apiType === "SQL") {
tooltipText += " For small read-heavy workloads or write-heavy workloads of any size, id is often a good choice.";
}
return tooltipText;
}
private getAnalyticalStorageTooltipContent(): JSX.Element {
return (
<Text variant="small">
Enable analytical store capability to perform near real-time analytics on your operational data, without
impacting the performance of transactional workloads.{" "}
<Link target="_blank" href="https://aka.ms/analytical-store-overview">
Learn more
</Link>
</Text>
);
}
private shouldShowCollectionThroughputInput(): boolean { private shouldShowCollectionThroughputInput(): boolean {
if (this.isServerlessAccount()) { if (this.isServerlessAccount()) {
return false; return false;
@ -879,6 +899,11 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return false; return false;
} }
if (throughput > CollectionCreation.MaxRUPerPartition && !this.state.isSharded) {
this.setState({ errorMessage: "Unsharded collections support up to 10,000 RUs" });
return false;
}
if ( if (
userContext.apiType === "Gremlin" && userContext.apiType === "Gremlin" &&
(this.state.partitionKey === "/id" || this.state.partitionKey === "/label") (this.state.partitionKey === "/id" || this.state.partitionKey === "/label")
@ -905,6 +930,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return Constants.AnalyticalStorageTtl.Disabled; return Constants.AnalyticalStorageTtl.Disabled;
} }
private scrollToAdvancedSection(): void {
document.getElementById("collapsibleSectionContent")?.scrollIntoView();
}
private async submit(event: React.FormEvent<HTMLFormElement>): Promise<void> { private async submit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault(); event.preventDefault();
@ -923,7 +952,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
} }
const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = this.parseUniqueKeys(); const uniqueKeyPolicy: DataModels.UniqueKeyPolicy = this.parseUniqueKeys();
const partitionKeyVersion = this.state.useHashV1 ? undefined : 2; const partitionKeyVersion = this.state.useHashV2 ? 2 : undefined;
const partitionKey: DataModels.PartitionKey = partitionKeyString const partitionKey: DataModels.PartitionKey = partitionKeyString
? { ? {
paths: [partitionKeyString], paths: [partitionKeyString],

View File

@ -238,7 +238,6 @@ export default class AddDatabasePane extends ContextualPaneBase {
userContext.portalEnv, userContext.portalEnv,
this.isFreeTierAccount(), this.isFreeTierAccount(),
this.container.isFirstResourceCreated(), this.container.isFirstResourceCreated(),
userContext.apiType,
false false
); );
}); });

View File

@ -9,6 +9,7 @@ import { DefaultExperienceUtility } from "../../../Shared/DefaultExperienceUtili
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import { getCollectionName } from "../../../Utils/APITypeUtils";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { import {
@ -17,14 +18,12 @@ import {
} from "../GenericRightPaneComponent/GenericRightPaneComponent"; } from "../GenericRightPaneComponent/GenericRightPaneComponent";
export interface DeleteCollectionConfirmationPaneProps { export interface DeleteCollectionConfirmationPaneProps {
explorer: Explorer; explorer: Explorer;
collectionName: string;
closePanel: () => void; closePanel: () => void;
} }
export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectionConfirmationPaneProps> = ({ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectionConfirmationPaneProps> = ({
explorer, explorer,
closePanel, closePanel,
collectionName,
}: DeleteCollectionConfirmationPaneProps) => { }: DeleteCollectionConfirmationPaneProps) => {
const [deleteCollectionFeedback, setDeleteCollectionFeedback] = useState<string>(""); const [deleteCollectionFeedback, setDeleteCollectionFeedback] = useState<string>("");
const [inputCollectionName, setInputCollectionName] = useState<string>(""); const [inputCollectionName, setInputCollectionName] = useState<string>("");
@ -34,6 +33,7 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
const shouldRecordFeedback = (): boolean => { const shouldRecordFeedback = (): boolean => {
return explorer.isLastCollection() && !explorer.isSelectedDatabaseShared(); return explorer.isLastCollection() && !explorer.isSelectedDatabaseShared();
}; };
const collectionName = getCollectionName().toLocaleLowerCase();
const paneTitle = "Delete " + collectionName; const paneTitle = "Delete " + collectionName;
const submit = async (): Promise<void> => { const submit = async (): Promise<void> => {
const collection = explorer.findSelectedCollection(); const collection = explorer.findSelectedCollection();

View File

@ -11,11 +11,11 @@
margin: 20px 0; margin: 20px 0;
overflow: auto; overflow: auto;
& > * { & > :not(.collapsibleSection) {
margin-bottom: @DefaultSpace; margin-bottom: @DefaultSpace;
& > * { & > :not(:last-child) {
margin-bottom: @SmallSpace; margin-bottom: @DefaultSpace;
} }
} }
@ -23,7 +23,6 @@
font-size: @mediumFontSize; font-size: @mediumFontSize;
width: @mediumFontSize; width: @mediumFontSize;
margin: auto 0 auto @SmallSpace; margin: auto 0 auto @SmallSpace;
color: @InfoIconColor;
cursor: default; cursor: default;
vertical-align: middle; vertical-align: middle;
} }
@ -49,10 +48,6 @@
font-size: @mediumFontSize; font-size: @mediumFontSize;
padding: 0 @LargeSpace 0 @SmallSpace; padding: 0 @LargeSpace 0 @SmallSpace;
} }
.collapsibleSection {
margin-bottom: 0;
}
} }
} }
@ -99,7 +94,7 @@
} }
.panelFooter { .panelFooter {
padding: 20px 34px; padding: 16px 34px;
border-top: solid 1px #bbbbbb; border-top: solid 1px #bbbbbb;
& button { & button {
@ -123,8 +118,8 @@
cursor: pointer; cursor: pointer;
} }
.panelGroupSpacing > * { .panelGroupSpacing > :not(:last-child) {
margin-bottom: @SmallSpace; margin-bottom: @DefaultSpace;
} }
.fileUpload { .fileUpload {
display: none !important; display: none !important;
@ -170,3 +165,6 @@
.column-select-view { .column-select-view {
margin: 20px 0px 0px 0px; margin: 20px 0px 0px 0px;
} }
.panelSeparator::before {
background-color: #edebe9;
}

View File

@ -1,5 +1,5 @@
import React from "react";
import { Icon, Link, Stack, Text } from "office-ui-fabric-react"; import { Icon, Link, Stack, Text } from "office-ui-fabric-react";
import React from "react";
export interface PanelInfoErrorProps { export interface PanelInfoErrorProps {
message: string; message: string;
@ -23,7 +23,7 @@ export const PanelInfoErrorComponent: React.FunctionComponent<PanelInfoErrorProp
} }
return ( return (
<Stack className="panelInfoErrorContainer" horizontal verticalAlign="start"> <Stack className="panelInfoErrorContainer" horizontal verticalAlign="center">
{icon} {icon}
<span className="panelWarningErrorDetailsLinkContainer"> <span className="panelWarningErrorDetailsLinkContainer">
<Text className="panelWarningErrorMessage" variant="small"> <Text className="panelWarningErrorMessage" variant="small">

View File

@ -525,7 +525,7 @@ exports[`Delete Database Confirmation Pane submit() Should call delete database
<Stack <Stack
className="panelInfoErrorContainer" className="panelInfoErrorContainer"
horizontal={true} horizontal={true}
verticalAlign="start" verticalAlign="center"
> >
<div <div
className="ms-Stack panelInfoErrorContainer css-140" className="ms-Stack panelInfoErrorContainer css-140"

View File

@ -205,9 +205,8 @@ export default class Database implements ViewModels.Database {
this.deleteCollectionsFromList(deltaCollections.toDelete); this.deleteCollectionsFromList(deltaCollections.toDelete);
} }
public openAddCollection(database: Database, event: MouseEvent) { public openAddCollection(database: Database) {
database.container.addCollectionPane.databaseId(database.id()); database.container.openAddCollectionPanel(database.id());
database.container.addCollectionPane.open();
} }
public findCollectionWithId(collectionId: string): ViewModels.Collection { public findCollectionWithId(collectionId: string): ViewModels.Collection {

View File

@ -197,7 +197,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
className: "databaseHeader", className: "databaseHeader",
children: [], children: [],
isSelected: () => this.isDataNodeSelected(database.id()), isSelected: () => this.isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(this.container), contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(this.container, database.id()),
onClick: async (isExpanded) => { onClick: async (isExpanded) => {
// Rewritten version of expandCollapseDatabase(): // Rewritten version of expandCollapseDatabase():
if (isExpanded) { if (isExpanded) {

View File

@ -115,6 +115,7 @@ export enum Action {
NotebooksGalleryFavoritesCount, NotebooksGalleryFavoritesCount,
NotebooksGalleryPublishedCount, NotebooksGalleryPublishedCount,
SelfServe, SelfServe,
ExpandAddCollectionPaneAdvancedSection,
} }
export const ActionModifiers = { export const ActionModifiers = {

View File

@ -19,7 +19,7 @@ interface UserContext {
readonly quotaId?: string; readonly quotaId?: string;
// API Type is not yet provided by ARM. You need to manually inspect all the capabilities+kind so we abstract that logic in userContext // API Type is not yet provided by ARM. You need to manually inspect all the capabilities+kind so we abstract that logic in userContext
// This is coming in a future Cosmos ARM API version as a prperty on databaseAccount // This is coming in a future Cosmos ARM API version as a prperty on databaseAccount
apiType?: ApiType; apiType: ApiType;
readonly isTryCosmosDBSubscription?: boolean; readonly isTryCosmosDBSubscription?: boolean;
readonly portalEnv?: PortalEnv; readonly portalEnv?: PortalEnv;
readonly features: Features; readonly features: Features;

30
src/Utils/APITypeUtils.ts Normal file
View File

@ -0,0 +1,30 @@
import { userContext } from "../UserContext";
export const getCollectionName = (isPlural?: boolean): string => {
let collectionName: string;
let unknownApiType: never;
switch (userContext.apiType) {
case "SQL":
collectionName = "Container";
break;
case "Mongo":
collectionName = "Collection";
break;
case "Cassandra":
case "Tables":
collectionName = "Table";
break;
case "Gremlin":
collectionName = "Graph";
break;
default:
unknownApiType = userContext.apiType;
throw new Error(`Unknown API type: ${unknownApiType}`);
}
if (isPlural) {
collectionName += "s";
}
return collectionName;
};

View File

@ -1,4 +1,5 @@
import * as Constants from "../Shared/Constants"; import * as Constants from "../Shared/Constants";
import { getCollectionName } from "../Utils/APITypeUtils";
import * as AutoPilotUtils from "../Utils/AutoPilotUtils"; import * as AutoPilotUtils from "../Utils/AutoPilotUtils";
interface ComputeRUUsagePriceHourlyArgs { interface ComputeRUUsagePriceHourlyArgs {
@ -10,7 +11,7 @@ interface ComputeRUUsagePriceHourlyArgs {
} }
export const estimatedCostDisclaimer = export const estimatedCostDisclaimer =
"*This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account"; "This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account";
/** /**
* Anything that is not a number should return 0 * Anything that is not a number should return 0
@ -161,7 +162,7 @@ export function getAutoPilotV3SpendHtml(maxAutoPilotThroughputSet: number, isDat
return ""; return "";
} }
const resource: string = isDatabaseThroughput ? "database" : "container"; const resource: string = isDatabaseThroughput ? "database" : getCollectionName().toLocaleLowerCase();
return `Your ${resource} throughput will automatically scale from <b>${AutoPilotUtils.getMinRUsBasedOnUserInput( return `Your ${resource} throughput will automatically scale from <b>${AutoPilotUtils.getMinRUsBasedOnUserInput(
maxAutoPilotThroughputSet maxAutoPilotThroughputSet
)} RU/s (10% of max RU/s) - ${maxAutoPilotThroughputSet} RU/s</b> based on usage. <br /><br />After the first ${AutoPilotUtils.getStorageBasedOnUserInput( )} RU/s (10% of max RU/s) - ${maxAutoPilotThroughputSet} RU/s</b> based on usage. <br /><br />After the first ${AutoPilotUtils.getStorageBasedOnUserInput(
@ -227,7 +228,7 @@ export function getEstimatedSpendHtml(
`${currencySign}${calculateEstimateNumber(monthlyPrice)} monthly </b> ` + `${currencySign}${calculateEstimateNumber(monthlyPrice)} monthly </b> ` +
`(${regions} ${regions === 1 ? "region" : "regions"}, ${throughput}RU/s, ${currencySign}${pricePerRu}/RU)` + `(${regions} ${regions === 1 ? "region" : "regions"}, ${throughput}RU/s, ${currencySign}${pricePerRu}/RU)` +
`<p style='padding: 10px 0px 0px 0px;'>` + `<p style='padding: 10px 0px 0px 0px;'>` +
`<em>${estimatedCostDisclaimer}</em></p>` `<em>*${estimatedCostDisclaimer}</em></p>`
); );
} }
@ -261,11 +262,10 @@ export function getUpsellMessage(
serverId = "default", serverId = "default",
isFreeTier = false, isFreeTier = false,
isFirstResourceCreated = false, isFirstResourceCreated = false,
defaultExperience: string,
isCollection: boolean isCollection: boolean
): string { ): string {
if (isFreeTier) { if (isFreeTier) {
const collectionName = getCollectionName(defaultExperience); const collectionName = getCollectionName().toLocaleLowerCase();
const resourceType = isCollection ? collectionName : "database"; const resourceType = isCollection ? collectionName : "database";
return isFirstResourceCreated return isFirstResourceCreated
? `The free tier discount of 400 RU/s has already been applied to a database or ${collectionName} in this account. Billing will apply to this ${resourceType} after it is created.` ? `The free tier discount of 400 RU/s has already been applied to a database or ${collectionName} in this account. Billing will apply to this ${resourceType} after it is created.`
@ -277,22 +277,8 @@ export function getUpsellMessage(
price = Constants.OfferPricing.MonthlyPricing.mooncake.Standard.StartingPrice; price = Constants.OfferPricing.MonthlyPricing.mooncake.Standard.StartingPrice;
} }
return `Start at ${getCurrencySign(serverId)}${price}/mo per database, multiple containers included`; return `Start at ${getCurrencySign(serverId)}${price}/mo per database, multiple ${getCollectionName(
} true
} ).toLocaleLowerCase()} included`;
export function getCollectionName(defaultExperience: string): string {
switch (defaultExperience) {
case "SQL":
return "container";
case "Mongo":
return "collection";
case "Tables":
case "Cassandra":
return "table";
case "Gremlin":
return "graph";
default:
throw Error("unknown API type");
} }
} }

View File

@ -16,15 +16,10 @@ test("Mongo CRUD", async () => {
// Create new database and collection // Create new database and collection
await explorer.click('[data-test="New Collection"]'); await explorer.click('[data-test="New Collection"]');
await explorer.click('[data-test="addCollection-newDatabaseId"]'); await explorer.fill('[aria-label="New database id"]', databaseId);
await explorer.fill('[data-test="addCollection-newDatabaseId"]', databaseId); await explorer.fill('[aria-label="Collection id"]', containerId);
await explorer.click('[data-test="addCollection-collectionId"]'); await explorer.fill('[aria-label="Shard key"]', "/pk");
await explorer.fill('[data-test="addCollection-collectionId"]', containerId); await explorer.click("#sidePanelOkButton");
await explorer.click('[data-test="addCollection-collectionId"]');
await explorer.fill('[data-test="addCollection-collectionId"]', containerId);
await explorer.click('[data-test="addCollection-partitionKeyValue"]');
await explorer.fill('[data-test="addCollection-partitionKeyValue"]', "/pk");
await explorer.click('[data-test="addCollection-createCollection"]');
await safeClick(explorer, `.nodeItem >> text=${databaseId}`); await safeClick(explorer, `.nodeItem >> text=${databaseId}`);
await safeClick(explorer, `.nodeItem >> text=${containerId}`); await safeClick(explorer, `.nodeItem >> text=${containerId}`);
// Create indexing policy // Create indexing policy

View File

@ -15,15 +15,10 @@ test("SQL CRUD", async () => {
}); });
await explorer.click('[data-test="New Container"]'); await explorer.click('[data-test="New Container"]');
await explorer.click('[data-test="addCollection-newDatabaseId"]'); await explorer.fill('[aria-label="New database id"]', databaseId);
await explorer.fill('[data-test="addCollection-newDatabaseId"]', databaseId); await explorer.fill('[aria-label="Container id"]', containerId);
await explorer.click('[data-test="addCollection-collectionId"]'); await explorer.fill('[aria-label="Partition key"]', "/pk");
await explorer.fill('[data-test="addCollection-collectionId"]', containerId); await explorer.click("#sidePanelOkButton");
await explorer.click('[data-test="addCollection-collectionId"]');
await explorer.fill('[data-test="addCollection-collectionId"]', containerId);
await explorer.click('[data-test="addCollection-partitionKeyValue"]');
await explorer.fill('[data-test="addCollection-partitionKeyValue"]', "/pk");
await explorer.click('[data-test="addCollection-createCollection"]');
await safeClick(explorer, `.nodeItem >> text=${databaseId}`); await safeClick(explorer, `.nodeItem >> text=${databaseId}`);
await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`);
await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Container")'); await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Container")');

View File

@ -15,9 +15,8 @@ test("Tables CRUD", async () => {
}); });
await explorer.click('[data-test="New Table"]'); await explorer.click('[data-test="New Table"]');
await explorer.click('[data-test="addCollection-collectionId"]'); await explorer.fill('[aria-label="Table id"]', tableId);
await explorer.fill('[data-test="addCollection-collectionId"]', tableId); await explorer.click("#sidePanelOkButton");
await explorer.click('[data-test="addCollection-createCollection"]');
await safeClick(explorer, `[data-test="TablesDB"]`); await safeClick(explorer, `[data-test="TablesDB"]`);
await safeClick(explorer, `[data-test="${tableId}"] [aria-label="More"]`); await safeClick(explorer, `[data-test="${tableId}"] [aria-label="More"]`);
await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Table")'); await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Table")');