Move add collection pane to React (#486)

* Move add collection pane to React

* Add feature flag

* fix unit tests

* FIx merge conflicts and address comments

* Resolve merge conflicts

* Address comments

* Fix e2e test failure

* Update test snapshots

* Update test snapshots
This commit is contained in:
victor-meng 2021-03-18 18:06:13 -07:00 committed by GitHub
parent c6090e2663
commit 65c859c835
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1891 additions and 419 deletions

View File

@ -120,6 +120,7 @@ export class Features {
public static readonly enableDatabaseSettingsTabV1 = "enabledbsettingsv1"; public static readonly enableDatabaseSettingsTabV1 = "enabledbsettingsv1";
public static readonly selfServeType = "selfservetype"; public static readonly selfServeType = "selfservetype";
public static readonly enableKOPanel = "enablekopanel"; public static readonly enableKOPanel = "enablekopanel";
public static readonly enableReactPane = "enablereactpane";
} }
// flight names returned from the portal are always lowercase // flight names returned from the portal are always lowercase

View File

@ -6,6 +6,7 @@ describe("CollapsibleSectionComponent", () => {
it("renders", () => { it("renders", () => {
const props: CollapsibleSectionProps = { const props: CollapsibleSectionProps = {
title: "Sample title", title: "Sample title",
isExpandedByDefault: true,
}; };
const wrapper = shallow(<CollapsibleSectionComponent {...props} />); const wrapper = shallow(<CollapsibleSectionComponent {...props} />);

View File

@ -1,9 +1,10 @@
import { Icon, Label, Stack } from "office-ui-fabric-react"; import { Icon, Label, Stack } from "office-ui-fabric-react";
import * as React from "react"; import * as React from "react";
import { accordionIconStyles, accordionStackTokens } from "../Settings/SettingsRenderUtils"; import { accordionStackTokens } from "../Settings/SettingsRenderUtils";
export interface CollapsibleSectionProps { export interface CollapsibleSectionProps {
title: string; title: string;
isExpandedByDefault: boolean;
} }
export interface CollapsibleSectionState { export interface CollapsibleSectionState {
@ -14,7 +15,7 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
constructor(props: CollapsibleSectionProps) { constructor(props: CollapsibleSectionProps) {
super(props); super(props);
this.state = { this.state = {
isExpanded: true, isExpanded: this.props.isExpandedByDefault,
}; };
} }
@ -25,8 +26,14 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
public render(): JSX.Element { public render(): JSX.Element {
return ( return (
<> <>
<Stack className="collapsibleSection" horizontal tokens={accordionStackTokens} onClick={this.toggleCollapsed}> <Stack
<Icon iconName={this.state.isExpanded ? "ChevronDown" : "ChevronRight"} styles={accordionIconStyles} /> className="collapsibleSection"
horizontal
verticalAlign="center"
tokens={accordionStackTokens}
onClick={this.toggleCollapsed}
>
<Icon iconName={this.state.isExpanded ? "ChevronDown" : "ChevronRight"} />
<Label>{this.props.title}</Label> <Label>{this.props.title}</Label>
</Stack> </Stack>
{this.state.isExpanded && this.props.children} {this.state.isExpanded && this.props.children}

View File

@ -11,16 +11,10 @@ exports[`CollapsibleSectionComponent renders 1`] = `
"childrenGap": 10, "childrenGap": 10,
} }
} }
verticalAlign="center"
> >
<Icon <Icon
iconName="ChevronDown" iconName="ChevronDown"
styles={
Object {
"root": Object {
"paddingTop": 7,
},
}
}
/> />
<StyledLabelBase> <StyledLabelBase>
Sample title Sample title

View File

@ -23,7 +23,6 @@ import {
ITextStyles, ITextStyles,
IDetailsRowStyles, IDetailsRowStyles,
IStackStyles, IStackStyles,
IIconStyles,
IDetailsListStyles, IDetailsListStyles,
IDropdownStyles, IDropdownStyles,
ISeparatorStyles, ISeparatorStyles,
@ -116,8 +115,6 @@ export const addMongoIndexSubElementsTokens: IStackTokens = {
childrenGap: 20, childrenGap: 20,
}; };
export const accordionIconStyles: IIconStyles = { root: { paddingTop: 7 } };
export const mediumWidthStackStyles: IStackStyles = { root: { width: 600 } }; export const mediumWidthStackStyles: IStackStyles = { root: { width: 600 } };
export const shortWidthTextFieldStyles: Partial<ITextFieldStyles> = { root: { paddingLeft: 10, width: 210 } }; export const shortWidthTextFieldStyles: Partial<ITextFieldStyles> = { root: { paddingLeft: 10, width: 210 } };

View File

@ -239,7 +239,7 @@ export class MongoIndexingPolicyComponent extends React.Component<MongoIndexingP
return ( return (
<Stack {...createAndAddMongoIndexStackProps} styles={mediumWidthStackStyles}> <Stack {...createAndAddMongoIndexStackProps} styles={mediumWidthStackStyles}>
<CollapsibleSectionComponent title="Current index(es)"> <CollapsibleSectionComponent title="Current index(es)" isExpandedByDefault={true}>
{ {
<> <>
<DetailsList <DetailsList
@ -266,7 +266,7 @@ export class MongoIndexingPolicyComponent extends React.Component<MongoIndexingP
return ( return (
<Stack styles={mediumWidthStackStyles}> <Stack styles={mediumWidthStackStyles}>
<CollapsibleSectionComponent title="Index(es) to be dropped"> <CollapsibleSectionComponent title="Index(es) to be dropped" isExpandedByDefault={true}>
{indexesToBeDropped.length > 0 && ( {indexesToBeDropped.length > 0 && (
<DetailsList <DetailsList
styles={customDetailsListStyles} styles={customDetailsListStyles}

View File

@ -42,6 +42,7 @@ exports[`MongoIndexingPolicyComponent renders 1`] = `
} }
> >
<CollapsibleSectionComponent <CollapsibleSectionComponent
isExpandedByDefault={true}
title="Current index(es)" title="Current index(es)"
> >
<StyledWithViewportComponent <StyledWithViewportComponent
@ -139,6 +140,7 @@ exports[`MongoIndexingPolicyComponent renders 1`] = `
} }
> >
<CollapsibleSectionComponent <CollapsibleSectionComponent
isExpandedByDefault={true}
title="Index(es) to be dropped" title="Index(es) to be dropped"
/> />
</Stack> </Stack>

View File

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

View File

@ -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<ThroughputInputProps, ThroughputInputState> {
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 (
<div className="throughputInputContainer throughputInputSpacing">
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text variant="small" style={{ lineHeight: "20px" }}>
{this.getThroughputLabelText()}
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={PricingUtils.getRuToolTipText()}>
<Icon iconName="InfoSolid" className="panelInfoIcon" />
</TooltipHost>
</Stack>
<Stack horizontal verticalAlign="center">
<input
className="throughputInputRadioBtn"
aria-label="Autoscale mode"
checked={this.state.isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={this.onAutoscaleRadioBtnChange.bind(this)}
/>
<span className="throughputInputRadioBtnLabel">Autoscale</span>
<input
className="throughputInputRadioBtn"
aria-label="Manual mode"
checked={!this.state.isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={this.onManualRadioBtnChange.bind(this)}
/>
<span className="throughputInputRadioBtnLabel">Manual</span>
</Stack>
{this.state.isAutoscaleSelected && (
<Stack className="throughputInputSpacing">
<Text variant="small">
Provision maximum RU/s required by this resource. Estimate your required RU/s with&nbsp;
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/">
capacity calculator
</Link>
.
</Text>
<Stack horizontal>
<Text variant="small" style={{ lineHeight: "20px" }}>
Max RU/s
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={this.getAutoScaleTooltip()}>
<Icon iconName="InfoSolid" className="panelInfoIcon" />
</TooltipHost>
</Stack>
<TextField
type="number"
styles={{
fieldGroup: { width: 300, height: 27 },
field: { fontSize: 12 },
}}
onChange={(event, newInput?: string) => this.onThroughputValueChange(newInput)}
step={AutoPilotUtils.autoPilotIncrementStep}
min={AutoPilotUtils.minAutoPilotThroughput}
value={this.state.throughput.toString()}
aria-label="Max request units per second"
required={true}
/>
<Text variant="small">
Your {this.props.isDatabase ? "database" : "container"} throughput will automatically scale from{" "}
<b>
{AutoPilotUtils.getMinRUsBasedOnUserInput(this.state.throughput)} RU/s (10% of max RU/s) -{" "}
{this.state.throughput} RU/s
</b>{" "}
based on usage.
</Text>
</Stack>
)}
{!this.state.isAutoscaleSelected && (
<Stack className="throughputInputSpacing">
<Text variant="small">
Estimate your required RU/s with&nbsp;
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/">
capacity calculator
</Link>
.
</Text>
<TooltipHost
directionalHint={DirectionalHint.topLeftEdge}
content={
this.props.showFreeTierExceedThroughputTooltip &&
this.state.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs400
? "The first 400 RU/s in this account are free. Billing will apply to any throughput beyond 400 RU/s."
: undefined
}
>
<TextField
type="number"
styles={{
fieldGroup: { width: 300, height: 27 },
field: { fontSize: 12 },
}}
onChange={(event, newInput?: string) => 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}
/>
</TooltipHost>
</Stack>
)}
<CostEstimateText requestUnits={this.state.throughput} isAutoscale={this.state.isAutoscaleSelected} />
{this.state.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && (
<Stack horizontal verticalAlign="start">
<Checkbox
checked={this.state.isCostAcknowledged}
styles={{
checkbox: { width: 12, height: 12 },
label: { padding: 0, margin: "4px 4px 0 0" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) => {
this.setState({ isCostAcknowledged: isChecked });
this.props.onCostAcknowledgeChange(isChecked);
}}
/>
<Text variant="small" style={{ lineHeight: "20px" }}>
{this.getCostAcknowledgeText()}
</Text>
</Stack>
)}
</div>
);
}
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<HTMLInputElement>): void {
if (event.target.checked && !this.state.isAutoscaleSelected) {
this.setState({ isAutoscaleSelected: true, throughput: AutoPilotUtils.minAutoPilotThroughput });
this.props.setIsAutoscale(true);
}
}
private onManualRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): 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<CostEstimateTextProps> = (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 (
<Text variant="small">
Estimated monthly cost ({currency}):{" "}
<b>
{currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice / 10)} -{" "}
{currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)}{" "}
</b>
({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits / 10} - {requestUnits}{" "}
RU/s, {currencySign + pricePerRu}/RU)
</Text>
);
}
return (
<Text variant="small">
Cost ({currency}):{" "}
<b>
{currencySign + PricingUtils.calculateEstimateNumber(hourlyPrice)} hourly /{" "}
{currencySign + PricingUtils.calculateEstimateNumber(dailyPrice)} daily /{" "}
{currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)} monthly{" "}
</b>
({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits}RU/s,{" "}
{currencySign + pricePerRu}/RU)
<br />
<em>{PricingUtils.estimatedCostDisclaimer}</em>
</Text>
);
};

View File

@ -48,6 +48,7 @@ import { FileSystemUtil } from "./Notebook/FileSystemUtil";
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
import { NotebookUtil } from "./Notebook/NotebookUtil"; import { NotebookUtil } from "./Notebook/NotebookUtil";
import AddCollectionPane from "./Panes/AddCollectionPane"; import AddCollectionPane from "./Panes/AddCollectionPane";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel";
import AddDatabasePane from "./Panes/AddDatabasePane"; import AddDatabasePane from "./Panes/AddDatabasePane";
import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane"; import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane";
import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane"; import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane";
@ -2392,11 +2393,13 @@ export default class Explorer {
public onNewCollectionClicked(): void { public onNewCollectionClicked(): void {
if (this.isPreferredApiCassandra()) { if (this.isPreferredApiCassandra()) {
this.cassandraAddCollectionPane.open(); this.cassandraAddCollectionPane.open();
} else if (this.isFeatureEnabled(Constants.Features.enableReactPane)) {
this.openAddCollectionPanel();
} else { } else {
this.addCollectionPane.open(this.selectedDatabaseId()); this.addCollectionPane.open(this.selectedDatabaseId());
}
document.getElementById("linkAddCollection").focus(); document.getElementById("linkAddCollection").focus();
} }
}
private refreshCommandBarButtons(): void { private refreshCommandBarButtons(): void {
const activeTab = this.tabsManager.activeTab(); const activeTab = this.tabsManager.activeTab();
@ -2535,4 +2538,16 @@ export default class Explorer {
/> />
); );
} }
public async openAddCollectionPanel(): Promise<void> {
await this.loadDatabaseOffers();
this.openSidePanel(
"New Collection",
<AddCollectionPanel
explorer={this}
closePanel={() => this.closeSidePanel()}
openNotificationConsole={() => this.expandConsole()}
/>
);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -133,7 +133,7 @@ describe("Delete Collection Confirmation Pane", () => {
.simulate("change", { target: { value: selectedCollectionId } }); .simulate("change", { target: { value: selectedCollectionId } });
expect(wrapper.exists("#sidePanelOkButton")).toBe(true); expect(wrapper.exists("#sidePanelOkButton")).toBe(true);
wrapper.find("#sidePanelOkButton").hostNodes().simulate("click"); wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit");
expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId); expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId);
wrapper.unmount(); wrapper.unmount();
@ -154,7 +154,7 @@ describe("Delete Collection Confirmation Pane", () => {
.simulate("change", { target: { value: feedbackText } }); .simulate("change", { target: { value: feedbackText } });
expect(wrapper.exists("#sidePanelOkButton")).toBe(true); expect(wrapper.exists("#sidePanelOkButton")).toBe(true);
wrapper.find("#sidePanelOkButton").hostNodes().simulate("click"); wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit");
expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId); expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId);
const deleteFeedback = new DeleteFeedback( const deleteFeedback = new DeleteFeedback(

View File

@ -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 { Text, TextField } from "office-ui-fabric-react";
import { userContext } from "../../UserContext"; import * as React from "react";
import { Areas } from "../../Common/Constants"; import { Areas } from "../../Common/Constants";
import { deleteCollection } from "../../Common/dataAccess/deleteCollection"; 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 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 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 { export interface DeleteCollectionConfirmationPanelProps {
explorer: Explorer; explorer: Explorer;
closePanel: () => void; closePanel: () => void;
@ -44,8 +43,8 @@ export class DeleteCollectionConfirmationPanel extends React.Component<
render(): JSX.Element { render(): JSX.Element {
return ( return (
<div className="panelContentContainer"> <form className="panelFormWrapper" onSubmit={this.submit.bind(this)}>
<PanelErrorComponent {...this.getPanelErrorProps()} /> <PanelInfoErrorComponent {...this.getPanelErrorProps()} />
<div className="panelMainContent"> <div className="panelMainContent">
<div className="confirmDeleteInput"> <div className="confirmDeleteInput">
<span className="mandatoryStar">* </span> <span className="mandatoryStar">* </span>
@ -79,18 +78,16 @@ export class DeleteCollectionConfirmationPanel extends React.Component<
</div> </div>
)} )}
</div> </div>
<PanelFooterComponent buttonLabel="OK" onOKButtonClicked={() => this.submit()} /> <PanelFooterComponent buttonLabel="OK" />
<div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" hidden={!this.state.isExecuting}> {this.state.isExecuting && <PanelLoadingScreen />}
<img className="dataExplorerLoader" src={LoadingIndicator_3Squares} /> </form>
</div>
</div>
); );
} }
private getPanelErrorProps(): PanelErrorProps { private getPanelErrorProps(): PanelInfoErrorProps {
if (this.state.formError) { if (this.state.formError) {
return { return {
isWarning: false, messageType: "error",
message: this.state.formError, message: this.state.formError,
showErrorDetails: true, showErrorDetails: true,
openNotificationConsole: this.props.openNotificationConsole, openNotificationConsole: this.props.openNotificationConsole,
@ -98,7 +95,7 @@ export class DeleteCollectionConfirmationPanel extends React.Component<
} }
return { return {
isWarning: true, messageType: "warning",
showErrorDetails: false, showErrorDetails: false,
message: message:
"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.",
@ -109,9 +106,10 @@ export class DeleteCollectionConfirmationPanel extends React.Component<
return this.props.explorer.isLastCollection() && !this.props.explorer.isSelectedDatabaseShared(); return this.props.explorer.isLastCollection() && !this.props.explorer.isSelectedDatabaseShared();
} }
public async submit(): Promise<void> { public async submit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
const collection = this.props.explorer.findSelectedCollection(); event.preventDefault();
const collection = this.props.explorer.findSelectedCollection();
if (!collection || this.inputCollectionName !== collection.id()) { if (!collection || this.inputCollectionName !== collection.id()) {
const errorMessage = "Input collection name does not match the selected collection"; const errorMessage = "Input collection name does not match the selected collection";
this.setState({ formError: errorMessage }); this.setState({ formError: errorMessage });

View File

@ -1,12 +1,58 @@
@import "../../../less/Common/Constants"; @import "../../../less/Common/Constants";
.panelContentContainer { .panelFormWrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
.panelMainContent { .panelMainContent {
flex-grow: 1; 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; font-weight: 400;
} }
.panelWarningErrorContainer { .panelInfoErrorContainer {
background-color: @BaseLow; background-color: @BaseLow;
padding: @DefaultSpace; padding: @DefaultSpace;
display: inline-flex; display: inline-flex;
margin-bottom: 24px; margin: 20px 34px 0 34px;
.panelWarningIcon { i {
font-size: @WarningErrorIconSize; font-size: @WarningErrorIconSize;
width: @WarningErrorIconSize; width: @WarningErrorIconSize;
margin: auto 0 auto @SmallSpace; margin-left: @SmallSpace;
}
.panelWarningIcon {
color: @WarningIconColor; color: @WarningIconColor;
} }
.panelErrorIcon { .panelErrorIcon {
font-size: @WarningErrorIconSize;
width: @WarningErrorIconSize;
margin: auto 0 auto @SmallSpace;
color: @ErrorIconColor; color: @ErrorIconColor;
} }
.panelLargeInfoIcon {
color: @InfoIconColor;
}
.panelWarningErrorDetailsLinkContainer { .panelWarningErrorDetailsLinkContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -48,10 +98,19 @@
} }
} }
.panelFooter button { .panelFooter {
padding: 20px 34px;
border-top: solid 1px #bbbbbb;
& button {
height: 30px; height: 30px;
}
} }
.deleteCollectionFeedback { .deleteCollectionFeedback {
margin-top: 12px; margin-top: 12px;
} }
.panelGroupSpacing > * {
margin-bottom: @SmallSpace;
}

View File

@ -9,10 +9,30 @@ export interface PanelContainerProps {
closePanel: () => void; closePanel: () => void;
} }
export class PanelContainerComponent extends React.Component<PanelContainerProps> { export interface PanelContainerState {
height: string;
}
export class PanelContainerComponent extends React.Component<PanelContainerProps, PanelContainerState> {
private static readonly consoleHeaderHeight = 32; private static readonly consoleHeaderHeight = 32;
private static readonly consoleContentHeight = 220; 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 { render(): JSX.Element {
if (!this.props.panelContent) { if (!this.props.panelContent) {
return <></>; return <></>;
@ -30,8 +50,10 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
headerClassName="panelHeader" headerClassName="panelHeader"
styles={{ styles={{
navigation: { borderBottom: "1px solid #cccccc" }, navigation: { borderBottom: "1px solid #cccccc" },
content: { padding: "24px 34px 20px 34px", height: "100%" }, content: { padding: 0, height: "100%" },
scrollableContent: { height: "100%" }, scrollableContent: { height: "100%" },
header: { padding: "0 0 8px 34px" },
commands: { marginTop: 8 },
}} }}
style={{ height: this.getPanelHeight() }} style={{ height: this.getPanelHeight() }}
> >

View File

@ -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<PanelErrorProps> = (props: PanelErrorProps): JSX.Element => (
<div className="panelWarningErrorContainer">
{props.isWarning ? (
<Icon iconName="WarningSolid" className="panelWarningIcon" />
) : (
<Icon iconName="StatusErrorFull" className="panelErrorIcon" />
)}
<span className="panelWarningErrorDetailsLinkContainer">
<Text className="panelWarningErrorMessage" variant="small">
{props.message}
</Text>
{props.showErrorDetails && (
<a className="paneErrorLink" role="link" onClick={props.openNotificationConsole}>
More details
</a>
)}
</span>
</div>
);

View File

@ -3,13 +3,12 @@ import { PrimaryButton } from "office-ui-fabric-react";
export interface PanelFooterProps { export interface PanelFooterProps {
buttonLabel: string; buttonLabel: string;
onOKButtonClicked: () => void;
} }
export const PanelFooterComponent: React.FunctionComponent<PanelFooterProps> = ( export const PanelFooterComponent: React.FunctionComponent<PanelFooterProps> = (
props: PanelFooterProps props: PanelFooterProps
): JSX.Element => ( ): JSX.Element => (
<div className="panelFooter"> <div className="panelFooter">
<PrimaryButton id="sidePanelOkButton" text={props.buttonLabel} onClick={() => props.onOKButtonClicked()} /> <PrimaryButton type="submit" id="sidePanelOkButton" text={props.buttonLabel} />
</div> </div>
); );

View File

@ -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<PanelInfoErrorProps> = (
props: PanelInfoErrorProps
): JSX.Element => {
let icon: JSX.Element;
if (props.messageType === "error") {
icon = <Icon iconName="StatusErrorFull" className="panelErrorIcon" />;
} else if (props.messageType === "warning") {
icon = <Icon iconName="WarningSolid" className="panelWarningIcon" />;
} else if (props.messageType === "info") {
icon = <Icon iconName="InfoSolid" className="panelLargeInfoIcon" />;
}
return (
<Stack className="panelInfoErrorContainer" horizontal verticalAlign="start">
{icon}
<span className="panelWarningErrorDetailsLinkContainer">
<Text className="panelWarningErrorMessage" variant="small">
{props.message}{" "}
{props.link && props.linkText && (
<Link target="_blank" href={props.link}>
{props.linkText}
</Link>
)}
</Text>
{props.showErrorDetails && (
<a className="paneErrorLink" role="link" onClick={props.openNotificationConsole}>
More details
</a>
)}
</span>
</Stack>
);
};

View File

@ -0,0 +1,8 @@
import React from "react";
import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif";
export const PanelLoadingScreen: React.FunctionComponent = () => (
<div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer">
<img className="dataExplorerLoader" src={LoadingIndicator_3Squares} />
</div>
);

View File

@ -15,20 +15,27 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
} }
openNotificationConsole={[Function]} openNotificationConsole={[Function]}
> >
<div <form
className="panelContentContainer" className="panelFormWrapper"
onSubmit={[Function]}
> >
<PanelErrorComponent <PanelInfoErrorComponent
isWarning={true}
message="Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources." message="Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources."
messageType="warning"
showErrorDetails={false} showErrorDetails={false}
>
<Stack
className="panelInfoErrorContainer"
horizontal={true}
verticalAlign="start"
> >
<div <div
className="panelWarningErrorContainer" className="ms-Stack panelInfoErrorContainer css-140"
> >
<StyledIconBase <StyledIconBase
className="panelWarningIcon" className="panelWarningIcon"
iconName="WarningSolid" iconName="WarningSolid"
key=".0:$.0"
> >
<IconBase <IconBase
className="panelWarningIcon" className="panelWarningIcon"
@ -310,7 +317,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
> >
<i <i
aria-hidden={true} aria-hidden={true}
className="panelWarningIcon root-141" className="panelWarningIcon root-142"
data-icon-name="WarningSolid" data-icon-name="WarningSolid"
> >
@ -319,20 +326,23 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
</StyledIconBase> </StyledIconBase>
<span <span
className="panelWarningErrorDetailsLinkContainer" className="panelWarningErrorDetailsLinkContainer"
key=".0:$.1"
> >
<Text <Text
className="panelWarningErrorMessage" className="panelWarningErrorMessage"
variant="small" variant="small"
> >
<span <span
className="panelWarningErrorMessage css-142" className="panelWarningErrorMessage css-143"
> >
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.
</span> </span>
</Text> </Text>
</span> </span>
</div> </div>
</PanelErrorComponent> </Stack>
</PanelInfoErrorComponent>
<div <div
className="panelMainContent" className="panelMainContent"
> >
@ -348,7 +358,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
variant="small" variant="small"
> >
<span <span
className="css-142" className="css-143"
> >
Confirm by typing the collection id Confirm by typing the collection id
</span> </span>
@ -649,18 +659,18 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
validateOnLoad={true} validateOnLoad={true}
> >
<div <div
className="ms-TextField root-144" className="ms-TextField root-145"
> >
<div <div
className="ms-TextField-wrapper" className="ms-TextField-wrapper"
> >
<div <div
className="ms-TextField-fieldGroup fieldGroup-145" className="ms-TextField-fieldGroup fieldGroup-146"
> >
<input <input
aria-invalid={false} aria-invalid={false}
autoFocus={true} autoFocus={true}
className="ms-TextField-field field-146" className="ms-TextField-field field-147"
id="confirmCollectionId" id="confirmCollectionId"
onBlur={[Function]} onBlur={[Function]}
onChange={[Function]} onChange={[Function]}
@ -683,7 +693,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
variant="small" variant="small"
> >
<span <span
className="css-155" className="css-156"
> >
Help us improve Azure Cosmos DB! Help us improve Azure Cosmos DB!
</span> </span>
@ -693,7 +703,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
variant="small" variant="small"
> >
<span <span
className="css-155" className="css-156"
> >
What is the reason why you are deleting this container? What is the reason why you are deleting this container?
</span> </span>
@ -996,17 +1006,17 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
validateOnLoad={true} validateOnLoad={true}
> >
<div <div
className="ms-TextField ms-TextField--multiline root-144" className="ms-TextField ms-TextField--multiline root-145"
> >
<div <div
className="ms-TextField-wrapper" className="ms-TextField-wrapper"
> >
<div <div
className="ms-TextField-fieldGroup fieldGroup-156" className="ms-TextField-fieldGroup fieldGroup-157"
> >
<textarea <textarea
aria-invalid={false} aria-invalid={false}
className="ms-TextField-field field-157" className="ms-TextField-field field-158"
id="deleteCollectionFeedbackInput" id="deleteCollectionFeedbackInput"
onBlur={[Function]} onBlur={[Function]}
onChange={[Function]} onChange={[Function]}
@ -1024,19 +1034,17 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
</div> </div>
<PanelFooterComponent <PanelFooterComponent
buttonLabel="OK" buttonLabel="OK"
onOKButtonClicked={[Function]}
> >
<div <div
className="panelFooter" className="panelFooter"
> >
<CustomizedPrimaryButton <CustomizedPrimaryButton
id="sidePanelOkButton" id="sidePanelOkButton"
onClick={[Function]}
text="OK" text="OK"
type="submit"
> >
<PrimaryButton <PrimaryButton
id="sidePanelOkButton" id="sidePanelOkButton"
onClick={[Function]}
text="OK" text="OK"
theme={ theme={
Object { Object {
@ -1311,10 +1319,10 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
}, },
} }
} }
type="submit"
> >
<CustomizedDefaultButton <CustomizedDefaultButton
id="sidePanelOkButton" id="sidePanelOkButton"
onClick={[Function]}
onRenderDescription={[Function]} onRenderDescription={[Function]}
primary={true} primary={true}
text="OK" text="OK"
@ -1591,10 +1599,10 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
}, },
} }
} }
type="submit"
> >
<DefaultButton <DefaultButton
id="sidePanelOkButton" id="sidePanelOkButton"
onClick={[Function]}
onRenderDescription={[Function]} onRenderDescription={[Function]}
primary={true} primary={true}
text="OK" text="OK"
@ -1871,11 +1879,11 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
}, },
} }
} }
type="submit"
> >
<BaseButton <BaseButton
baseClassName="ms-Button" baseClassName="ms-Button"
id="sidePanelOkButton" id="sidePanelOkButton"
onClick={[Function]}
onRenderDescription={[Function]} onRenderDescription={[Function]}
primary={true} primary={true}
split={false} split={false}
@ -2696,10 +2704,11 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
}, },
} }
} }
type="submit"
variantClassName="ms-Button--primary" variantClassName="ms-Button--primary"
> >
<button <button
className="ms-Button ms-Button--primary root-159" className="ms-Button ms-Button--primary root-160"
data-is-focusable={true} data-is-focusable={true}
id="sidePanelOkButton" id="sidePanelOkButton"
onClick={[Function]} onClick={[Function]}
@ -2708,17 +2717,17 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
onKeyUp={[Function]} onKeyUp={[Function]}
onMouseDown={[Function]} onMouseDown={[Function]}
onMouseUp={[Function]} onMouseUp={[Function]}
type="button" type="submit"
> >
<span <span
className="ms-Button-flexContainer flexContainer-160" className="ms-Button-flexContainer flexContainer-161"
data-automationid="splitbuttonprimary" data-automationid="splitbuttonprimary"
> >
<span <span
className="ms-Button-textContainer textContainer-161" className="ms-Button-textContainer textContainer-162"
> >
<span <span
className="ms-Button-label label-163" className="ms-Button-label label-164"
id="id__6" id="id__6"
key="id__6" key="id__6"
> >
@ -2735,15 +2744,6 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
</CustomizedPrimaryButton> </CustomizedPrimaryButton>
</div> </div>
</PanelFooterComponent> </PanelFooterComponent>
<div </form>
className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer"
hidden={true}
>
<img
className="dataExplorerLoader"
src=""
/>
</div>
</div>
</DeleteCollectionConfirmationPanel> </DeleteCollectionConfirmationPanel>
`; `;

View File

@ -16,9 +16,15 @@ exports[`PaneContainerComponent test should be resize if notification console is
} }
styles={ styles={
Object { Object {
"commands": Object {
"marginTop": 8,
},
"content": Object { "content": Object {
"height": "100%", "height": "100%",
"padding": "24px 34px 20px 34px", "padding": 0,
},
"header": Object {
"padding": "0 0 8px 34px",
}, },
"navigation": Object { "navigation": Object {
"borderBottom": "1px solid #cccccc", "borderBottom": "1px solid #cccccc",
@ -52,9 +58,15 @@ exports[`PaneContainerComponent test should render with panel content and header
} }
styles={ styles={
Object { Object {
"commands": Object {
"marginTop": 8,
},
"content": Object { "content": Object {
"height": "100%", "height": "100%",
"padding": "24px 34px 20px 34px", "padding": 0,
},
"header": Object {
"padding": "0 0 8px 34px",
}, },
"navigation": Object { "navigation": Object {
"borderBottom": "1px solid #cccccc", "borderBottom": "1px solid #cccccc",

View File

@ -45,6 +45,7 @@ import "./Explorer/Controls/DynamicList/DynamicListComponent.less";
import "./Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.less"; import "./Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.less";
import "./Explorer/Controls/JsonEditor/JsonEditorComponent.less"; import "./Explorer/Controls/JsonEditor/JsonEditorComponent.less";
import "./Explorer/Controls/Notebook/NotebookTerminalComponent.less"; import "./Explorer/Controls/Notebook/NotebookTerminalComponent.less";
import "./Explorer/Controls/ThroughputInput/ThroughputInput.less";
import "./Explorer/Controls/TreeComponent/treeComponent.less"; import "./Explorer/Controls/TreeComponent/treeComponent.less";
import { ExplorerParams } from "./Explorer/Explorer"; import { ExplorerParams } from "./Explorer/Explorer";
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less"; import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";