diff --git a/.eslintignore b/.eslintignore index fee7d9f6b..c2f9c30d8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -87,7 +87,7 @@ src/Explorer/DataSamples/ContainerSampleGenerator.test.ts src/Explorer/DataSamples/ContainerSampleGenerator.ts src/Explorer/DataSamples/DataSamplesUtil.test.ts src/Explorer/DataSamples/DataSamplesUtil.ts -src/Explorer/Explorer.ts +src/Explorer/Explorer.tsx src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.test.ts src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e623886b4..981514009 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,6 +166,8 @@ jobs: PORTAL_RUNNER_RESOURCE_GROUP: ${{ secrets.PORTAL_RUNNER_RESOURCE_GROUP }} PORTAL_RUNNER_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT }} PORTAL_RUNNER_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY }} + PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT }} + PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY }} NOTEBOOKS_TEST_RUNNER_TENANT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_TENANT_ID }} NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }} NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }} diff --git a/configs/mpac.json b/configs/mpac.json index dd9a572a1..e06baa8ec 100644 --- a/configs/mpac.json +++ b/configs/mpac.json @@ -1,3 +1,4 @@ { - "JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com" + "JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com", + "ENABLE_GALLERY_PUBLISH": true } diff --git a/images/notebook/publish_content.svg b/images/notebook/publish_content.svg new file mode 100644 index 000000000..a6f1bbcd1 --- /dev/null +++ b/images/notebook/publish_content.svg @@ -0,0 +1,3 @@ + + + diff --git a/less/Common/Constants.less b/less/Common/Constants.less index bc8616929..8039450b4 100644 --- a/less/Common/Constants.less +++ b/less/Common/Constants.less @@ -57,6 +57,13 @@ @FocusColor: #605e5c; +@GalleryBackgroundColor: #fdfdfd; + +//Icons +@InfoIconColor: #0072c6; +@WarningIconColor: #db7500; +@ErrorIconColor: #b91f26; + /****************************************************************************** METRICS /******************************************************************************/ diff --git a/less/documentDB.less b/less/documentDB.less index c3526109e..72ed44c00 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -1523,6 +1523,21 @@ p { .tooltipVisible(); } +.inputTooltip { + .inputTooltip(); +} + +.inputTooltip .inputTooltipText { + top: -68px; + .inputTooltipText(); +} + +.inputTooltip .inputTooltipText::after { + border-width: @MediumSpace @MediumSpace 0 @MediumSpace; + top: 55px; + .inputTooltipTextAfter(); +} + .infoTooltip a { color: @AccentHigh; } @@ -3028,3 +3043,45 @@ settings-pane { .collapsibleSection :hover { cursor: pointer; } + +.messageBarInfoIcon { + color: @InfoIconColor; +} + +.messageBarWarningIcon { + color: @WarningIconColor; +} + +.freeTierInfoBanner { + background-color: @BaseLow; + display: inline-flex; + padding: @DefaultSpace; + width: 100%; + + .freeTierInfoIcon img { + height: 28px; + width: 28px; + margin-left: 4px; + } + + .freeTierInfoMessage { + margin: auto 0; + padding-left: @MediumSpace; + } +} + +.freeTierInlineWarning { + display: inline-flex; + padding: 8px 8px 8px 0; + width: 100%; + + .freeTierWarningIcon img { + height: 20px; + width: 20px; + } + + .freeTierWarningMessage { + margin: auto 0; + padding-left: @SmallSpace; + } +} diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 9f7214d76..a3ce1d6c6 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -119,7 +119,9 @@ export class Features { public static readonly enableSchema = "enableschema"; public static readonly enableSDKoperations = "enablesdkoperations"; public static readonly showMinRUSurvey = "showminrusurvey"; + public static readonly enableDatabaseSettingsTabV1 = "enabledbsettingsv1"; public static readonly selfServeType = "selfservetype"; + public static readonly enableKOPanel = "enablekopanel"; } // flight names returned from the portal are always lowercase @@ -128,6 +130,7 @@ export class Flights { public static readonly MongoIndexEditor = "mongoindexeditor"; public static readonly MongoIndexing = "mongoindexing"; public static readonly AutoscaleTest = "autoscaletest"; + public static readonly GalleryPublish = "gallerypublish"; } export class AfecFeatures { diff --git a/src/Common/dataAccess/getCollectionDataUsageSize.ts b/src/Common/dataAccess/getCollectionDataUsageSize.ts index 94718d630..da332ad37 100644 --- a/src/Common/dataAccess/getCollectionDataUsageSize.ts +++ b/src/Common/dataAccess/getCollectionDataUsageSize.ts @@ -76,7 +76,7 @@ export const getCollectionUsageSizeInKB = async (databaseName: string, container return dataUsageSizeInKb + indexUsageSizeInKb; } catch (error) { handleError(error, "getCollectionUsageSize"); - throw error; + return undefined; } }; diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index 18a1f1fa0..66cc14d00 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -26,6 +26,7 @@ export interface ConfigContext { GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it. hostedExplorerURL: string; armAPIVersion?: string; + ENABLE_GALLERY_PUBLISH?: boolean; } // Default configuration @@ -79,7 +80,11 @@ if (process.env.NODE_ENV === "development") { export async function initializeConfiguration(): Promise { try { - const response = await fetch("./config.json"); + const response = await fetch("./config.json", { + headers: { + "If-None-Match": "", // disable client side cache + }, + }); if (response.status === 200) { try { const { allowedParentFrameOrigins, ...externalConfig } = await response.json(); diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 3244ad4bb..7526973c4 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -91,6 +91,7 @@ export interface Database extends TreeNode { onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void; onSettingsClick: () => void; loadOffer(): Promise; + getPendingThroughputSplitNotification(): Promise; } export interface CollectionBase extends TreeNode { @@ -137,7 +138,6 @@ export interface Collection extends CollectionBase { openTab(): void; onSettingsClick: () => Promise; - onDeleteCollectionContextMenuClick(source: Collection, event: MouseEvent): void; onNewGraphClick(): void; onNewMongoQueryClick(source: any, event: MouseEvent, queryText?: string): void; @@ -178,6 +178,7 @@ export interface Collection extends CollectionBase { uploadFiles(fileList: FileList): Promise; getLabel(): string; + getPendingThroughputSplitNotification(): Promise; } /** @@ -292,10 +293,6 @@ export interface DocumentsTabOptions extends TabOptions { resourceTokenPartitionKey?: string; } -export interface SettingsTabV2Options extends TabOptions { - getPendingNotification: Promise; -} - export interface ConflictsTabOptions extends TabOptions { partitionKey: DataModels.PartitionKey; conflictIds: ko.ObservableArray; @@ -362,7 +359,8 @@ export enum CollectionTabKind { Gallery = 17, NotebookViewer = 18, Schema = 19, - SettingsV2 = 20, + CollectionSettingsV2 = 20, + DatabaseSettingsV2 = 21, } export enum TerminalKind { diff --git a/src/Explorer/ComponentRegisterer.test.ts b/src/Explorer/ComponentRegisterer.test.ts index f55d41185..7ad1dba5b 100644 --- a/src/Explorer/ComponentRegisterer.test.ts +++ b/src/Explorer/ComponentRegisterer.test.ts @@ -45,7 +45,8 @@ describe("Component Registerer", () => { }); it("should register settings-tab-v2 component", () => { - expect(ko.components.isRegistered("settings-tab-v2")).toBe(true); + expect(ko.components.isRegistered("database-settings-tab-v2")).toBe(true); + expect(ko.components.isRegistered("collection-settings-tab-v2")).toBe(true); }); it("should register query-tab component", () => { diff --git a/src/Explorer/ComponentRegisterer.ts b/src/Explorer/ComponentRegisterer.ts index 4d49a52bf..c7e7ee087 100644 --- a/src/Explorer/ComponentRegisterer.ts +++ b/src/Explorer/ComponentRegisterer.ts @@ -31,7 +31,7 @@ ko.components.register("mongo-documents-tab", new TabComponents.MongoDocumentsTa ko.components.register("stored-procedure-tab", new TabComponents.StoredProcedureTab()); ko.components.register("trigger-tab", new TabComponents.TriggerTab()); ko.components.register("user-defined-function-tab", new TabComponents.UserDefinedFunctionTab()); -ko.components.register("settings-tab-v2", new TabComponents.SettingsTabV2()); +ko.components.register("collection-settings-tab-v2", new TabComponents.SettingsTabV2()); ko.components.register("query-tab", new TabComponents.QueryTab()); ko.components.register("tables-query-tab", new TabComponents.QueryTablesTab()); ko.components.register("graph-tab", new TabComponents.GraphTab()); @@ -45,6 +45,7 @@ ko.components.register("notebook-viewer-tab", new TabComponents.NotebookViewerTa // Database Tabs ko.components.register("database-settings-tab", new TabComponents.DatabaseSettingsTab()); +ko.components.register("database-settings-tab-v2", new TabComponents.SettingsTabV2()); // Panes ko.components.register("add-database-pane", new PaneComponents.AddDatabasePaneComponent()); diff --git a/src/Explorer/ContextMenuButtonFactory.ts b/src/Explorer/ContextMenuButtonFactory.ts index 8aaf8cce9..53dfe9370 100644 --- a/src/Explorer/ContextMenuButtonFactory.ts +++ b/src/Explorer/ContextMenuButtonFactory.ts @@ -112,10 +112,7 @@ export class ResourceTreeContextMenuButtonFactory { items.push({ iconSrc: DeleteCollectionIcon, - onClick: () => { - const selectedCollection: ViewModels.Collection = container.findSelectedCollection(); - selectedCollection && selectedCollection.onDeleteCollectionContextMenuClick(selectedCollection, null); - }, + onClick: () => container.openDeleteCollectionConfirmationPane(), label: container.deleteCollectionText(), styleClass: "deleteCollectionMenuItem", }); diff --git a/src/Explorer/Controls/DialogReactComponent/DialogComponent.tsx b/src/Explorer/Controls/DialogReactComponent/DialogComponent.tsx index d1994e98b..961dc0b0c 100644 --- a/src/Explorer/Controls/DialogReactComponent/DialogComponent.tsx +++ b/src/Explorer/Controls/DialogReactComponent/DialogComponent.tsx @@ -3,7 +3,13 @@ import { Dialog, DialogType, DialogFooter, IDialogProps } from "office-ui-fabric import { IButtonProps, PrimaryButton, DefaultButton } from "office-ui-fabric-react/lib/Button"; import { ITextFieldProps, TextField } from "office-ui-fabric-react/lib/TextField"; import { Link } from "office-ui-fabric-react/lib/Link"; -import { ChoiceGroup, FontIcon, IChoiceGroupProps } from "office-ui-fabric-react"; +import { + ChoiceGroup, + FontIcon, + IChoiceGroupProps, + IProgressIndicatorProps, + ProgressIndicator, +} from "office-ui-fabric-react"; export interface TextFieldProps extends ITextFieldProps { label: string; @@ -27,6 +33,7 @@ export interface DialogProps { choiceGroupProps?: IChoiceGroupProps; textFieldProps?: TextFieldProps; linkProps?: LinkProps; + progressIndicatorProps?: IProgressIndicatorProps; primaryButtonText: string; secondaryButtonText: string; onPrimaryButtonClick: () => void; @@ -62,13 +69,14 @@ export class DialogComponent extends React.Component { showCloseButton: this.props.showCloseButton || false, onDismiss: this.props.onDismiss, }, - modalProps: { isBlocking: this.props.isModal }, + modalProps: { isBlocking: this.props.isModal, isDarkOverlay: false }, minWidth: DIALOG_MIN_WIDTH, maxWidth: DIALOG_MAX_WIDTH, }; const choiceGroupProps: IChoiceGroupProps = this.props.choiceGroupProps; const textFieldProps: ITextFieldProps = this.props.textFieldProps; const linkProps: LinkProps = this.props.linkProps; + const progressIndicatorProps: IProgressIndicatorProps = this.props.progressIndicatorProps; const primaryButtonProps: IButtonProps = { text: this.props.primaryButtonText, disabled: this.props.primaryButtonDisabled || false, @@ -91,6 +99,7 @@ export class DialogComponent extends React.Component { {linkProps.linkText} )} + {progressIndicatorProps && } {secondaryButtonProps && } diff --git a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx index 37c976668..7fd7f6ff7 100644 --- a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx @@ -18,7 +18,6 @@ import * as React from "react"; import { IGalleryItem } from "../../../../Juno/JunoClient"; import { FileSystemUtil } from "../../../Notebook/FileSystemUtil"; import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg"; -import { StyleConstants } from "../../../../Common/Constants"; export interface GalleryCardComponentProps { data: IGalleryItem; @@ -38,7 +37,7 @@ export class GalleryCardComponent extends React.Component - {this.props.data.tags?.map((tag, index, array) => ( - - this.onClick(event, () => this.props.onTagClick(tag))}>{tag} - {index === array.length - 1 ? <> : ", "} - - ))} + {this.props.data.tags ? ( + this.props.data.tags.map((tag, index, array) => ( + + this.onClick(event, () => this.props.onTagClick(tag))}>{tag} + {index === array.length - 1 ? <> : ", "} + + )) + ) : ( +
+ )}
- {this.props.data.description.substr(0, GalleryCardComponent.cardDescriptionMaxChars)} + {this.renderTruncatedDescription()} - {this.generateIconText("RedEye", this.props.data.views.toString())} - {this.generateIconText("Download", this.props.data.downloads.toString())} - {this.props.isFavorite !== undefined && + {this.props.data.views !== undefined && this.generateIconText("RedEye", this.props.data.views.toString())} + {this.props.data.downloads !== undefined && + this.generateIconText("Download", this.props.data.downloads.toString())} + {this.props.data.favorites !== undefined && this.generateIconText("Heart", this.props.data.favorites.toString())} @@ -127,7 +132,7 @@ export class GalleryCardComponent extends React.Component { + let truncatedDescription = this.props.data.description.substr(0, GalleryCardComponent.cardDescriptionMaxChars); + if (this.props.data.description.length > GalleryCardComponent.cardDescriptionMaxChars) { + truncatedDescription = `${truncatedDescription} ...`; + } + return truncatedDescription; + }; + private generateIconText = (iconName: string, text: string): JSX.Element => { return ( - + {text} ); diff --git a/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap index 065688fa4..17b066081 100644 --- a/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap @@ -5,6 +5,11 @@ exports[`GalleryCardComponent renders 1`] = ` aria-label="name" data-is-focusable="true" onClick={[Function]} + style={ + Object { + "background": "white", + } + } tokens={ Object { "childrenGap": 0, @@ -88,7 +93,7 @@ exports[`GalleryCardComponent renders 1`] = ` styles={ Object { "root": Object { - "color": undefined, + "color": "#605E5C", "paddingRight": 8, }, } @@ -112,7 +117,7 @@ exports[`GalleryCardComponent renders 1`] = ` styles={ Object { "root": Object { - "color": undefined, + "color": "#605E5C", "paddingRight": 8, }, } @@ -136,7 +141,7 @@ exports[`GalleryCardComponent renders 1`] = ` styles={ Object { "root": Object { - "color": undefined, + "color": "#605E5C", "paddingRight": 8, }, } @@ -185,7 +190,7 @@ exports[`GalleryCardComponent renders 1`] = ` "gapSpace": 0, } } - content="Like" + content="Favorite" id="TooltipHost-IconButton-Heart" styles={ Object { @@ -197,14 +202,14 @@ exports[`GalleryCardComponent renders 1`] = ` } > { + private viewCodeOfConductTraced: boolean; private descriptionPara1: string; private descriptionPara2: string; private descriptionPara3: string; private link1: { label: string; url: string }; - private link2: { label: string; url: string }; constructor(props: CodeOfConductComponentProps) { super(props); @@ -27,23 +29,34 @@ export class CodeOfConductComponent extends React.Component { + const startKey = traceStart(Action.NotebooksGalleryAcceptCodeOfConduct); + try { const response = await this.props.junoClient.acceptCodeOfConduct(); if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { throw new Error(`Received HTTP ${response.status} when accepting code of conduct`); } + traceSuccess(Action.NotebooksGalleryAcceptCodeOfConduct, startKey); + this.props.onAcceptCodeOfConduct(response.data); } catch (error) { + traceFailure( + Action.NotebooksGalleryAcceptCodeOfConduct, + { + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + handleError(error, "CodeOfConductComponent/acceptCodeOfConduct", "Failed to accept code of conduct"); } } @@ -53,6 +66,11 @@ export class CodeOfConductComponent extends React.Component @@ -69,10 +87,6 @@ export class CodeOfConductComponent extends React.Component {this.link1.label} - {" and "} - - {this.link2.label} - @@ -87,7 +101,7 @@ export class CodeOfConductComponent extends React.Component diff --git a/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent.tsx index 3e2b5f605..6a3d3280e 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponent.tsx @@ -7,6 +7,7 @@ import Explorer from "../../Explorer"; export interface GalleryAndNotebookViewerComponentProps { container?: Explorer; + isGalleryPublishEnabled: boolean; junoClient: JunoClient; notebookUrl?: string; galleryItem?: IGalleryItem; @@ -60,6 +61,7 @@ export class GalleryAndNotebookViewerComponent extends React.Component< const props: GalleryViewerComponentProps = { container: this.props.container, + isGalleryPublishEnabled: this.props.isGalleryPublishEnabled, junoClient: this.props.junoClient, selectedTab: this.state.selectedTab, sortBy: this.state.sortBy, diff --git a/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponentAdapter.tsx b/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponentAdapter.tsx index b41b55337..dde238c1e 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponentAdapter.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryAndNotebookViewerComponentAdapter.tsx @@ -7,14 +7,20 @@ import { } from "./GalleryAndNotebookViewerComponent"; export class GalleryAndNotebookViewerComponentAdapter implements ReactAdapter { + private key: string; public parameters: ko.Observable; constructor(private props: GalleryAndNotebookViewerComponentProps) { + this.reset(); this.parameters = ko.observable(Date.now()); } public renderComponent(): JSX.Element { - return ; + return ; + } + + public reset(): void { + this.key = `GalleryAndNotebookViewerComponent-${Date.now()}`; } public triggerRender(): void { diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.less b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.less index b0261f751..2fcbdd780 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.less +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.less @@ -6,4 +6,16 @@ overflow-y: auto; width: 100%; font-family: @DataExplorerFont; + background: @GalleryBackgroundColor; +} + +.publicGalleryTabContainer { + position: relative; + height: 100vh; +} + +.publicGalleryTabOverlayContent { + background: white; + padding: 20px; + margin: 10%; } diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx index d5e2adecf..a290428b2 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx @@ -5,6 +5,7 @@ import { GalleryViewerComponent, GalleryViewerComponentProps, GalleryTab, SortBy describe("GalleryViewerComponent", () => { it("renders", () => { const props: GalleryViewerComponentProps = { + isGalleryPublishEnabled: false, junoClient: undefined, selectedTab: GalleryTab.OfficialSamples, sortBy: SortBy.MostViewed, diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx index addc71223..4fe41075f 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx @@ -9,10 +9,14 @@ import { IPivotProps, IRectangle, Label, + Link, List, + Overlay, Pivot, PivotItem, SearchBox, + Spinner, + SpinnerSize, Stack, Text, } from "office-ui-fabric-react"; @@ -27,9 +31,12 @@ import Explorer from "../../Explorer"; import { CodeOfConductComponent } from "./CodeOfConductComponent"; import { InfoComponent } from "./InfoComponent/InfoComponent"; import { handleError } from "../../../Common/ErrorHandlingUtils"; +import { trace } from "../../../Shared/Telemetry/TelemetryProcessor"; +import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; export interface GalleryViewerComponentProps { container?: Explorer; + isGalleryPublishEnabled: boolean; junoClient: JunoClient; selectedTab: GalleryTab; sortBy: SortBy; @@ -64,6 +71,8 @@ interface GalleryViewerComponentState { searchText: string; dialogProps: DialogProps; isCodeOfConductAccepted: boolean; + isFetchingPublishedNotebooks: boolean; + isFetchingFavouriteNotebooks: boolean; } interface GalleryTabInfo { @@ -74,18 +83,24 @@ interface GalleryTabInfo { export class GalleryViewerComponent extends React.Component { public static readonly OfficialSamplesTitle = "Official samples"; public static readonly PublicGalleryTitle = "Public gallery"; - public static readonly FavoritesTitle = "Liked"; - public static readonly PublishedTitle = "Your published work"; + public static readonly FavoritesTitle = "My favorites"; + public static readonly PublishedTitle = "My published work"; private static readonly rowsPerPage = 5; private static readonly mostViewedText = "Most viewed"; private static readonly mostDownloadedText = "Most downloaded"; - private static readonly mostFavoritedText = "Most liked"; + private static readonly mostFavoritedText = "Most favorited"; private static readonly mostRecentText = "Most recent"; private readonly sortingOptions: IDropdownOption[]; + private viewGalleryTraced: boolean; + private viewOfficialSamplesTraced: boolean; + private viewPublicGalleryTraced: boolean; + private viewFavoritesTraced: boolean; + private viewPublishedNotebooksTraced: boolean; + private sampleNotebooks: IGalleryItem[]; private publicNotebooks: IGalleryItem[]; private favoriteNotebooks: IGalleryItem[]; @@ -107,6 +122,8 @@ export class GalleryViewerComponent extends React.Component { + if (!this.viewGalleryTraced) { + this.viewGalleryTraced = true; + trace(Action.NotebooksGalleryViewGallery); + } + + switch (this.state.selectedTab) { + case GalleryTab.OfficialSamples: + if (!this.viewOfficialSamplesTraced) { + this.resetViewGalleryTabTracedFlags(); + this.viewOfficialSamplesTraced = true; + trace(Action.NotebooksGalleryViewOfficialSamples); + } + break; + case GalleryTab.PublicGallery: + if (!this.viewPublicGalleryTraced) { + this.resetViewGalleryTabTracedFlags(); + this.viewPublicGalleryTraced = true; + trace(Action.NotebooksGalleryViewPublicGallery); + } + break; + case GalleryTab.Favorites: + if (!this.viewFavoritesTraced) { + this.resetViewGalleryTabTracedFlags(); + this.viewFavoritesTraced = true; + trace(Action.NotebooksGalleryViewFavorites); + } + break; + case GalleryTab.Published: + if (!this.viewPublishedNotebooksTraced) { + this.resetViewGalleryTabTracedFlags(); + this.viewPublishedNotebooksTraced = true; + trace(Action.NotebooksGalleryViewPublishedNotebooks); + } + break; + default: + throw new Error(`Unknown selected tab ${this.state.selectedTab}`); + } + }; + + private resetViewGalleryTabTracedFlags = (): void => { + this.viewOfficialSamplesTraced = false; + this.viewPublicGalleryTraced = false; + this.viewFavoritesTraced = false; + this.viewPublishedNotebooksTraced = false; + }; + private isEmptyData = (data: IGalleryItem[]): boolean => { return !data || data.length === 0; }; - private createEmptyTabContent = (iconName: string, line1: string, line2: string): JSX.Element => { + private createEmptyTabContent = (iconName: string, line1: JSX.Element, line2: JSX.Element): JSX.Element => { return ( @@ -216,40 +280,63 @@ export class GalleryViewerComponent extends React.Component { + if (this.isEmptyData(data)) { + if (this.state.isFetchingFavouriteNotebooks) { + return ; + } + return this.createEmptyTabContent( + "ContactHeart", + <>You don't have any favorites yet, + <> + Favorite any notebook from the{" "} + this.setState({ selectedTab: GalleryTab.OfficialSamples })}>official samples or{" "} + this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery + + ); + } + return this.createSearchBarHeader(this.createCardsTabContent(data)); + }; + private createFavoritesTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo { return { tab, - content: this.isEmptyData(data) - ? this.createEmptyTabContent( - "ContactHeart", - "You have not liked anything", - "Like any notebook from Official Samples or Public gallery" - ) - : this.createSearchBarHeader(this.createCardsTabContent(data)), + content: this.getFavouriteNotebooksTabContent(data), }; } + private getPublishedNotebooksTabContent = (data: IGalleryItem[]) => { + if (this.isEmptyData(data)) { + if (this.state.isFetchingPublishedNotebooks) { + return ; + } + return this.createEmptyTabContent( + "Contact", + <> + You have not published anything to the{" "} + this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery yet + , + <>Publish your notebooks to share your work with other users + ); + } + return this.createPublishedNotebooksTabContent(data); + }; + private createPublishedNotebooksTab = (tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo => { return { tab, - content: this.isEmptyData(data) - ? this.createEmptyTabContent( - "Contact", - "You have not published anything", - "Publish your sample notebooks to share your published work with others" - ) - : this.createPublishedNotebooksTabContent(data), + content: this.getPublishedNotebooksTabContent(data), }; }; private createPublishedNotebooksTabContent = (data: IGalleryItem[]): JSX.Element => { const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(data); const content = ( - + {published?.length > 0 && this.createPublishedNotebooksSectionContent( undefined, - "You have successfully published the following notebook(s) to public gallery and shared with other Azure Cosmos DB users.", + "You have successfully published and shared the following notebook(s) to the public gallery.", this.createCardsTabContent(published) )} {underReview?.length > 0 && @@ -276,24 +363,33 @@ export class GalleryViewerComponent extends React.Component { return ( - - {title && {title}} - {description && {description}} + + {title && ( + {title} + )} + {description && {description}} {content} ); }; private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element { - return acceptedCodeOfConduct === false ? ( - { - this.setState({ isCodeOfConductAccepted: result }); - }} - /> - ) : ( - this.createSearchBarHeader(this.createCardsTabContent(data)) + return ( +
+ {this.createSearchBarHeader(this.createCardsTabContent(data))} + {acceptedCodeOfConduct === false && ( + +
+ { + this.setState({ isCodeOfConductAccepted: result }); + }} + /> +
+
+ )} +
); } @@ -310,7 +406,7 @@ export class GalleryViewerComponent extends React.Component - {(!this.props.container || this.props.container.isGalleryPublishEnabled()) && ( + {this.props.isGalleryPublishEnabled && ( @@ -322,7 +418,7 @@ export class GalleryViewerComponent extends React.Component + ) : ( + ); } private createPolicyViolationsListContent(data: IGalleryItem[]): JSX.Element { return ( - +
@@ -385,6 +483,10 @@ export class GalleryViewerComponent extends React.Component { if (!offline) { try { + this.setState({ isFetchingFavouriteNotebooks: true }); const response = await this.props.junoClient.getFavoriteNotebooks(); if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { throw new Error(`Received HTTP ${response.status} when loading favorite notebooks`); } this.favoriteNotebooks = response.data; + + trace(Action.NotebooksGalleryFavoritesCount, ActionModifiers.Mark, { count: this.favoriteNotebooks?.length }); } catch (error) { handleError(error, "GalleryViewerComponent/loadFavoriteNotebooks", "Failed to load favorite notebooks"); + } finally { + this.setState({ isFetchingFavouriteNotebooks: false }); } } @@ -451,14 +560,25 @@ export class GalleryViewerComponent extends React.Component { if (!offline) { try { + this.setState({ isFetchingPublishedNotebooks: true }); const response = await this.props.junoClient.getPublishedNotebooks(); if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { throw new Error(`Received HTTP ${response.status} when loading published notebooks`); } this.publishedNotebooks = response.data; + + const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(this.publishedNotebooks); + trace(Action.NotebooksGalleryPublishedCount, ActionModifiers.Mark, { + count: this.publishedNotebooks?.length, + publishedCount: published.length, + underReviewCount: underReview.length, + removedCount: removed.length, + }); } catch (error) { handleError(error, "GalleryViewerComponent/loadPublishedNotebooks", "Failed to load published notebooks"); + } finally { + this.setState({ isFetchingPublishedNotebooks: false }); } } diff --git a/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap index 3362852dd..e080d5797 100644 --- a/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap @@ -17,35 +17,28 @@ exports[`CodeOfConductComponent renders 1`] = ` } } > - Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement + Azure Cosmos DB Notebook Gallery - Code of Conduct - Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB. + The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB. - In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the + In order to view and publish your samples to the gallery, you must accept the - code of conduct - - and - - privacy statement + code of conduct. - - - - - - + diff --git a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx index d175aa183..19962c554 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx @@ -31,6 +31,26 @@ export interface NotebookMetadataComponentProps { } export class NotebookMetadataComponent extends React.Component { + private renderFavouriteButton = (): JSX.Element => { + return ( + + {this.props.isFavorite !== undefined ? ( + <> + + {this.props.data.favorites} likes + + ) : ( + <> + {this.props.data.favorites} likes + + )} + + ); + }; + public render(): JSX.Element { const options: Intl.DateTimeFormatOptions = { year: "numeric", @@ -49,19 +69,7 @@ export class NotebookMetadataComponent extends React.Component - - - {this.props.isFavorite !== undefined && ( - <> - - {this.props.data.favorites} likes - - )} - - + {this.renderFavouriteButton()} {this.props.downloadButtonText && ( diff --git a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx index 6ad7d1e9b..5a49b9952 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx @@ -3,14 +3,11 @@ */ import { Notebook } from "@nteract/commutable"; import { createContentRef } from "@nteract/core"; -import { IChoiceGroupProps, Icon, Link, ProgressIndicator } from "office-ui-fabric-react"; +import { IChoiceGroupProps, Icon, IProgressIndicatorProps, Link, ProgressIndicator } from "office-ui-fabric-react"; import * as React from "react"; import { contents } from "rx-jupyter"; -import * as Logger from "../../../Common/Logger"; import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; import * as GalleryUtils from "../../../Utils/GalleryUtils"; -import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; -import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; import { NotebookClientV2 } from "../../Notebook/NotebookClientV2"; import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper"; import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer"; @@ -21,7 +18,9 @@ import Explorer from "../../Explorer"; import { NotebookV4 } from "@nteract/commutable/lib/v4"; import { SessionStorageUtility } from "../../../Shared/StorageUtility"; import { DialogHost } from "../../../Utils/GalleryUtils"; -import { getErrorMessage, handleError } from "../../../Common/ErrorHandlingUtils"; +import { getErrorMessage, getErrorStack, handleError } from "../../../Common/ErrorHandlingUtils"; +import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor"; +import { Action } from "../../../Shared/Telemetry/TelemetryConstants"; export interface NotebookViewerComponentProps { container?: Explorer; @@ -80,6 +79,12 @@ export class NotebookViewerComponent } private async loadNotebookContent(): Promise { + const startKey = traceStart(Action.NotebooksGalleryViewNotebook, { + notebookUrl: this.props.notebookUrl, + notebookId: this.props.galleryItem?.id, + isSample: this.props.galleryItem?.isSample, + }); + try { const response = await fetch(this.props.notebookUrl); if (!response.ok) { @@ -87,6 +92,16 @@ export class NotebookViewerComponent throw new Error(`Received HTTP ${response.status} while fetching ${this.props.notebookUrl}`); } + traceSuccess( + Action.NotebooksGalleryViewNotebook, + { + notebookUrl: this.props.notebookUrl, + notebookId: this.props.galleryItem?.id, + isSample: this.props.galleryItem?.isSample, + }, + startKey + ); + const notebook: Notebook = await response.json(); this.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId); this.notebookComponentBootstrapper.setContent("json", notebook); @@ -101,6 +116,18 @@ export class NotebookViewerComponent SessionStorageUtility.setEntry(this.props.galleryItem?.id, "true"); } } catch (error) { + traceFailure( + Action.NotebooksGalleryViewNotebook, + { + notebookUrl: this.props.notebookUrl, + notebookId: this.props.galleryItem?.id, + isSample: this.props.galleryItem?.isSample, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + this.setState({ showProgressBar: false }); handleError(error, "NotebookViewerComponent/loadNotebookContent", "Failed to load notebook content"); } @@ -178,6 +205,32 @@ export class NotebookViewerComponent }; } + // DialogHost + showOkModalDialog( + title: string, + msg: string, + okLabel: string, + onOk: () => void, + progressIndicatorProps?: IProgressIndicatorProps + ): void { + this.setState({ + dialogProps: { + isModal: true, + visible: true, + title, + subText: msg, + primaryButtonText: okLabel, + onPrimaryButtonClick: () => { + this.setState({ dialogProps: undefined }); + onOk && onOk(); + }, + secondaryButtonText: undefined, + onSecondaryButtonClick: undefined, + progressIndicatorProps, + }, + }); + } + // DialogHost showOkCancelModalDialog( title: string, @@ -186,8 +239,10 @@ export class NotebookViewerComponent onOk: () => void, cancelLabel: string, onCancel: () => void, + progressIndicatorProps?: IProgressIndicatorProps, choiceGroupProps?: IChoiceGroupProps, - textFieldProps?: TextFieldProps + textFieldProps?: TextFieldProps, + primaryButtonDisabled?: boolean ): void { this.setState({ dialogProps: { @@ -205,8 +260,10 @@ export class NotebookViewerComponent this.setState({ dialogProps: undefined }); onCancel && onCancel(); }, + progressIndicatorProps, choiceGroupProps, textFieldProps, + primaryButtonDisabled, }, }); } diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index 6e439e5b3..3d3d31c91 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -2,7 +2,7 @@ import { shallow } from "enzyme"; import React from "react"; import { SettingsComponentProps, SettingsComponent, SettingsComponentState } from "./SettingsComponent"; import * as ViewModels from "../../../Contracts/ViewModels"; -import SettingsTabV2 from "../../Tabs/SettingsTabV2"; +import { CollectionSettingsTabV2 } from "../../Tabs/SettingsTabV2"; import { collection } from "./TestUtils"; import * as DataModels from "../../../Contracts/DataModels"; import ko from "knockout"; @@ -37,16 +37,15 @@ jest.mock("../../../Common/dataAccess/updateOffer", () => ({ describe("SettingsComponent", () => { const baseProps: SettingsComponentProps = { - settingsTab: new SettingsTabV2({ + settingsTab: new CollectionSettingsTabV2({ collection: collection, - tabKind: ViewModels.CollectionTabKind.SettingsV2, + tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2, title: "Scale & Settings", tabPath: "", node: undefined, hashLocation: "settings", isActive: ko.observable(false), onUpdateTabsButtons: undefined, - getPendingNotification: Promise.resolve(undefined), }), }; @@ -139,6 +138,7 @@ describe("SettingsComponent", () => { readSettings: undefined, onSettingsClick: undefined, loadOffer: undefined, + getPendingThroughputSplitNotification: undefined, } as ViewModels.Database; newCollection.getDatabase = () => newDatabase; newCollection.offer = ko.observable(undefined); diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 63e271918..354fe5929 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -11,7 +11,7 @@ import Explorer from "../../Explorer"; import { updateOffer } from "../../../Common/dataAccess/updateOffer"; import { updateCollection, updateMongoDBCollectionThroughRP } from "../../../Common/dataAccess/updateCollection"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; -import SettingsTab from "../../Tabs/SettingsTabV2"; +import { SettingsTabV2 } from "../../Tabs/SettingsTabV2"; import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils"; import { ScaleComponent, ScaleComponentProps } from "./SettingsSubComponents/ScaleComponent"; import { @@ -58,7 +58,7 @@ interface ButtonV2 { } export interface SettingsComponentProps { - settingsTab: SettingsTab; + settingsTab: SettingsTabV2; } export interface SettingsComponentState { @@ -116,7 +116,10 @@ export class SettingsComponent extends React.Component { - const autoscaleMaxThroughput = this.collection?.offer()?.autoscaleMaxThroughput; + const autoscaleMaxThroughput = this.offer?.autoscaleMaxThroughput; if (autoscaleMaxThroughput && AutoPilotUtils.isValidAutoPilotThroughput(autoscaleMaxThroughput)) { this.setState({ @@ -295,7 +309,7 @@ export class SettingsComponent extends React.Component { - return this.collection?.offer()?.offerReplacePending; + return this.offer?.offerReplacePending; }; public onSaveClick = async (): Promise => { @@ -309,174 +323,10 @@ export class SettingsComponent extends React.Component { @@ -693,6 +544,17 @@ export class SettingsComponent extends React.Component { + const offerThroughput = this.offer?.manualThroughput; + + if (!this.isCollectionSettingsTab) { + this.setState({ + throughput: offerThroughput, + throughputBaseline: offerThroughput, + }); + + return; + } + const defaultTtl = this.collection.defaultTtl(); let timeToLive: TtlType = this.state.timeToLive; @@ -725,7 +587,6 @@ export class SettingsComponent extends React.Component => { + if (this.state.isScaleSaveable) { + const updateOfferParams: DataModels.UpdateOfferParams = { + databaseId: this.database.id(), + currentOffer: this.database.offer(), + autopilotThroughput: this.state.isAutoPilotSelected ? this.state.autoPilotThroughput : undefined, + manualThroughput: this.state.isAutoPilotSelected ? undefined : this.state.throughput, + }; + if (this.hasProvisioningTypeChanged()) { + if (this.state.isAutoPilotSelected) { + updateOfferParams.migrateToAutoPilot = true; + } else { + updateOfferParams.migrateToManual = true; + } + } + const updatedOffer: DataModels.Offer = await updateOffer(updateOfferParams); + this.database.offer(updatedOffer); + this.offer = updatedOffer; + this.setState({ isScaleSaveable: false, isScaleDiscardable: false }); + if (this.state.isAutoPilotSelected) { + this.setState({ + autoPilotThroughput: updatedOffer.autoscaleMaxThroughput, + autoPilotThroughputBaseline: updatedOffer.autoscaleMaxThroughput, + }); + } else { + this.setState({ + throughput: updatedOffer.manualThroughput, + throughputBaseline: updatedOffer.manualThroughput, + }); + } + } + + this.container.isRefreshingExplorer(false); + this.setBaseline(); + this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected }); + traceSuccess( + Action.SettingsV2Updated, + { + databaseAccountName: this.container.databaseAccount()?.name, + databaseName: this.database.id(), + defaultExperience: this.container.defaultExperience(), + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.props.settingsTab.tabTitle(), + }, + startKey + ); + }; + + private saveCollectionSettings = async (startKey: number): Promise => { + const newCollection: DataModels.Collection = { ...this.collection.rawDataModel }; + + if (this.state.isSubSettingsSaveable || this.state.isIndexingPolicyDirty || this.state.isConflictResolutionDirty) { + let defaultTtl: number; + switch (this.state.timeToLive) { + case TtlType.On: + defaultTtl = Number(this.state.timeToLiveSeconds); + break; + case TtlType.OnNoDefault: + defaultTtl = -1; + break; + case TtlType.Off: + default: + defaultTtl = undefined; + break; + } + + const wasIndexingPolicyModified = this.state.isIndexingPolicyDirty; + newCollection.defaultTtl = defaultTtl; + + newCollection.indexingPolicy = this.state.indexingPolicyContent; + + newCollection.changeFeedPolicy = + this.changeFeedPolicyVisible && this.state.changeFeedPolicy === ChangeFeedPolicyState.On + ? { + retentionDuration: Constants.BackendDefaults.maxChangeFeedRetentionDuration, + } + : undefined; + + newCollection.analyticalStorageTtl = this.getAnalyticalStorageTtl(); + + newCollection.geospatialConfig = { + type: this.state.geospatialConfigType, + }; + + const conflictResolutionChanges: DataModels.ConflictResolutionPolicy = this.getUpdatedConflictResolutionPolicy(); + if (conflictResolutionChanges) { + newCollection.conflictResolutionPolicy = conflictResolutionChanges; + } + + const updatedCollection: DataModels.Collection = await updateCollection( + this.collection.databaseId, + this.collection.id(), + newCollection + ); + this.collection.rawDataModel = updatedCollection; + this.collection.defaultTtl(updatedCollection.defaultTtl); + this.collection.analyticalStorageTtl(updatedCollection.analyticalStorageTtl); + this.collection.id(updatedCollection.id); + this.collection.indexingPolicy(updatedCollection.indexingPolicy); + this.collection.conflictResolutionPolicy(updatedCollection.conflictResolutionPolicy); + this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy); + this.collection.geospatialConfig(updatedCollection.geospatialConfig); + + if (wasIndexingPolicyModified) { + await this.refreshIndexTransformationProgress(); + } + + this.setState({ + isSubSettingsSaveable: false, + isSubSettingsDiscardable: false, + isIndexingPolicyDirty: false, + isConflictResolutionDirty: false, + }); + } + + if (this.state.isMongoIndexingPolicySaveable && this.mongoDBCollectionResource) { + try { + const newMongoIndexes = this.getMongoIndexesToSave(); + const newMongoCollection: MongoDBCollectionResource = { + ...this.mongoDBCollectionResource, + indexes: newMongoIndexes, + }; + + this.mongoDBCollectionResource = await updateMongoDBCollectionThroughRP( + this.collection.databaseId, + this.collection.id(), + newMongoCollection + ); + + await this.refreshIndexTransformationProgress(); + this.setState({ + isMongoIndexingPolicySaveable: false, + indexesToDrop: [], + indexesToAdd: [], + currentMongoIndexes: [...this.mongoDBCollectionResource.indexes], + }); + traceSuccess( + Action.MongoIndexUpdated, + { + databaseAccountName: this.container.databaseAccount()?.name, + databaseName: this.collection?.databaseId, + collectionName: this.collection?.id(), + defaultExperience: this.container.defaultExperience(), + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.props.settingsTab.tabTitle(), + }, + startKey + ); + } catch (error) { + traceFailure( + Action.MongoIndexUpdated, + { + databaseAccountName: this.container.databaseAccount()?.name, + databaseName: this.collection?.databaseId, + collectionName: this.collection?.id(), + defaultExperience: this.container.defaultExperience(), + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.props.settingsTab.tabTitle(), + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + throw error; + } + } + + if (this.state.isScaleSaveable) { + const updateOfferParams: DataModels.UpdateOfferParams = { + databaseId: this.collection.databaseId, + collectionId: this.collection.id(), + currentOffer: this.collection.offer(), + autopilotThroughput: this.state.isAutoPilotSelected ? this.state.autoPilotThroughput : undefined, + manualThroughput: this.state.isAutoPilotSelected ? undefined : this.state.throughput, + }; + if (this.hasProvisioningTypeChanged()) { + if (this.state.isAutoPilotSelected) { + updateOfferParams.migrateToAutoPilot = true; + } else { + updateOfferParams.migrateToManual = true; + } + } + const updatedOffer: DataModels.Offer = await updateOffer(updateOfferParams); + this.collection.offer(updatedOffer); + this.offer = updatedOffer; + this.setState({ isScaleSaveable: false, isScaleDiscardable: false }); + if (this.state.isAutoPilotSelected) { + this.setState({ + autoPilotThroughput: updatedOffer.autoscaleMaxThroughput, + autoPilotThroughputBaseline: updatedOffer.autoscaleMaxThroughput, + }); + } else { + this.setState({ + throughput: updatedOffer.manualThroughput, + throughputBaseline: updatedOffer.manualThroughput, + }); + } + } + this.container.isRefreshingExplorer(false); + this.setBaseline(); + this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected }); + traceSuccess( + Action.SettingsV2Updated, + { + databaseAccountName: this.container.databaseAccount()?.name, + databaseName: this.collection?.databaseId, + collectionName: this.collection?.id(), + defaultExperience: this.container.defaultExperience(), + dataExplorerArea: Constants.Areas.Tab, + tabTitle: this.props.settingsTab.tabTitle(), + }, + startKey + ); + }; + public render(): JSX.Element { const scaleComponentProps: ScaleComponentProps = { collection: this.collection, + database: this.database, container: this.container, isFixedContainer: this.isFixedContainer, onThroughputChange: this.onThroughputChange, @@ -830,6 +907,16 @@ export class SettingsComponent extends React.Component +
+ +
+ + ); + } + const subSettingsComponentProps: SubSettingsComponentProps = { collection: this.collection, container: this.container, @@ -899,7 +986,7 @@ export class SettingsComponent extends React.Component, diff --git a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx index 003b665f8..b110723d3 100644 --- a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx @@ -375,7 +375,7 @@ export const getThroughputApplyShortDelayMessage = ( A request to increase the throughput is currently in progress. This operation will take some time to complete.
- Database: {databaseName}, Container: {collectionName}{" "} + {collectionName ? `Database: ${databaseName}, Container: ${collectionName} ` : `Database: ${databaseName} `} {getCurrentThroughput(isAutoscale, throughput, throughputUnit)}
); @@ -392,7 +392,7 @@ export const getThroughputApplyLongDelayMessage = ( A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
- Database: {databaseName}, Container: {collectionName}{" "} + {collectionName ? `Database: ${databaseName}, Container: ${collectionName} ` : `Database: ${databaseName} `} {getCurrentThroughput(isAutoscale, throughput, throughputUnit, requestedThroughput)} ); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.test.tsx index 54b6cc5e4..de31b2d5f 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.test.tsx @@ -18,6 +18,7 @@ describe("ScaleComponent", () => { const baseProps: ScaleComponentProps = { collection: collection, + database: undefined, container: container, isFixedContainer: false, onThroughputChange: () => { diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx index 8819024cb..a45ee4230 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx @@ -21,6 +21,7 @@ import { configContext, Platform } from "../../../../ConfigContext"; export interface ScaleComponentProps { collection: ViewModels.Collection; + database: ViewModels.Database; container: Explorer; isFixedContainer: boolean; onThroughputChange: (newThroughput: number) => void; @@ -39,9 +40,16 @@ export interface ScaleComponentProps { export class ScaleComponent extends React.Component { private isEmulator: boolean; + private offer: DataModels.Offer; + private databaseId: string; + private collectionId: string; + constructor(props: ScaleComponentProps) { super(props); this.isEmulator = configContext.platform === Platform.Emulator; + this.offer = this.props.database?.offer() || this.props.collection?.offer(); + this.databaseId = this.props.database?.id() || this.props.collection.databaseId; + this.collectionId = this.props.collection?.id(); } public isAutoScaleEnabled = (): boolean => { @@ -87,9 +95,7 @@ export class ScaleComponent extends React.Component { return SharedConstants.CollectionCreation.DefaultCollectionRUs400; } - return ( - this.props.collection.offer()?.minimumThroughput || SharedConstants.CollectionCreation.DefaultCollectionRUs400 - ); + return this.offer?.minimumThroughput || SharedConstants.CollectionCreation.DefaultCollectionRUs400; }; public getThroughputTitle = (): string => { @@ -115,15 +121,14 @@ export class ScaleComponent extends React.Component { return this.getLongDelayMessage(); } - const offer = this.props.collection?.offer(); - if (offer?.offerReplacePending) { - const throughput = offer.manualThroughput || offer.autoscaleMaxThroughput; + if (this.offer?.offerReplacePending) { + const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput; return getThroughputApplyShortDelayMessage( this.props.isAutoPilotSelected, throughput, throughputUnit, - this.props.collection.databaseId, - this.props.collection.id() + this.databaseId, + this.collectionId ); } @@ -135,7 +140,7 @@ export class ScaleComponent extends React.Component { this.canThroughputExceedMaximumValue() && this.props.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million; - if (throughputExceedsBackendLimits && !!this.props.collection.partitionKey && !this.props.isFixedContainer) { + if (throughputExceedsBackendLimits && !this.props.isFixedContainer) { return updateThroughputBeyondLimitWarningMessage; } @@ -154,8 +159,8 @@ export class ScaleComponent extends React.Component { this.props.wasAutopilotOriginallySet, throughput, throughputUnit, - this.props.collection.databaseId, - this.props.collection.id(), + this.databaseId, + this.collectionId, targetThroughput ); } @@ -165,15 +170,15 @@ export class ScaleComponent extends React.Component { private getThroughputInputComponent = (): JSX.Element => ( { onScaleSaveableChange={this.props.onScaleSaveableChange} onScaleDiscardableChange={this.props.onScaleDiscardableChange} getThroughputWarningMessage={this.getThroughputWarningMessage} - usageSizeInKB={this.props.collection.usageSizeInKB()} + usageSizeInKB={this.props.collection?.usageSizeInKB()} /> ); @@ -230,7 +235,7 @@ export class ScaleComponent extends React.Component { {!this.isAutoScaleEnabled() && ( {this.getThroughputInputComponent()} - {this.getStorageCapacityTitle()} + {!this.props.database && this.getStorageCapacityTitle()} )} diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx index 02316edcf..8515ae0ad 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx @@ -40,6 +40,7 @@ import { userContext } from "../../../../../UserContext"; import { SubscriptionType } from "../../../../../Contracts/SubscriptionType"; import { usageInGB, calculateEstimateNumber } from "../../../../../Utils/PricingUtils"; import { Features } from "../../../../../Common/Constants"; +import { minAutoPilotThroughput } from "../../../../../Utils/AutoPilotUtils"; import * as TelemetryProcessor from "../../../../../Shared/Telemetry/TelemetryProcessor"; import { Action, ActionModifiers } from "../../../../../Shared/Telemetry/TelemetryConstants"; @@ -541,6 +542,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< step={AutoPilotUtils.autoPilotIncrementStep} value={this.overrideWithProvisionedThroughputSettings() ? "" : this.props.maxAutoPilotThroughput?.toString()} onChange={this.onAutoPilotThroughputChange} + min={minAutoPilotThroughput} /> {!this.overrideWithProvisionedThroughputSettings() && this.getAutoPilotUsageCost()} {this.minRUperGBSurvey()} @@ -579,6 +581,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< : this.props.throughput?.toString() } onChange={this.onThroughputChange} + min={this.props.minimum} /> {this.state.exceedFreeTierThroughput && ( A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
- Database: - test - , Container: - test - + Database: test, Container: test , Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s diff --git a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx index cd1adbfee..2c040e769 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx @@ -46,6 +46,7 @@ describe("SettingsUtils", () => { readSettings: undefined, onSettingsClick: undefined, loadOffer: undefined, + getPendingThroughputSplitNotification: undefined, } as ViewModels.Database; }; newCollection.offer(undefined); diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index a9084dbc3..eb2b6b943 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -804,6 +804,7 @@ exports[`SettingsComponent renders 1`] = ` }, "clickHostedAccountSwitch": [Function], "clickHostedDirectorySwitch": [Function], + "closeSidePanel": undefined, "collapsedResourceTreeWidth": 36, "collectionCreationDefaults": Object { "storage": "100", @@ -1021,6 +1022,7 @@ exports[`SettingsComponent renders 1`] = ` "onRefreshResourcesClick": [Function], "onSwitchToConnectionString": [Function], "onToggleKeyDown": [Function], + "openSidePanel": undefined, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], @@ -2083,6 +2085,7 @@ exports[`SettingsComponent renders 1`] = ` }, "clickHostedAccountSwitch": [Function], "clickHostedDirectorySwitch": [Function], + "closeSidePanel": undefined, "collapsedResourceTreeWidth": 36, "collectionCreationDefaults": Object { "storage": "100", @@ -2300,6 +2303,7 @@ exports[`SettingsComponent renders 1`] = ` "onRefreshResourcesClick": [Function], "onSwitchToConnectionString": [Function], "onToggleKeyDown": [Function], + "openSidePanel": undefined, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], @@ -3375,6 +3379,7 @@ exports[`SettingsComponent renders 1`] = ` }, "clickHostedAccountSwitch": [Function], "clickHostedDirectorySwitch": [Function], + "closeSidePanel": undefined, "collapsedResourceTreeWidth": 36, "collectionCreationDefaults": Object { "storage": "100", @@ -3592,6 +3597,7 @@ exports[`SettingsComponent renders 1`] = ` "onRefreshResourcesClick": [Function], "onSwitchToConnectionString": [Function], "onToggleKeyDown": [Function], + "openSidePanel": undefined, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], @@ -4654,6 +4660,7 @@ exports[`SettingsComponent renders 1`] = ` }, "clickHostedAccountSwitch": [Function], "clickHostedDirectorySwitch": [Function], + "closeSidePanel": undefined, "collapsedResourceTreeWidth": 36, "collectionCreationDefaults": Object { "storage": "100", @@ -4871,6 +4878,7 @@ exports[`SettingsComponent renders 1`] = ` "onRefreshResourcesClick": [Function], "onSwitchToConnectionString": [Function], "onToggleKeyDown": [Function], + "openSidePanel": undefined, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap index 25cb13989..474866fb9 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap @@ -256,11 +256,7 @@ exports[`SettingsUtils functions render 1`] = ` > A request to increase the throughput is currently in progress. This operation will take some time to complete.
- Database: - sampleDb - , Container: - sampleCollection - + Database: sampleDb, Container: sampleCollection , Current manual throughput: 1000 RU/s A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
- Database: - sampleDb - , Container: - sampleCollection - + Database: sampleDb, Container: sampleCollection , Current manual throughput: 1000 RU/s, Target manual throughput: 2000
void; setNotificationConsoleData: (consoleData: ConsoleData) => void; setInProgressConsoleDataIdToBeDeleted: (id: string) => void; + openSidePanel: (headerText: string, panelContent: JSX.Element) => void; + closeSidePanel: () => void; } export default class Explorer { @@ -157,6 +161,8 @@ export default class Explorer { // Panes public contextPanes: ContextualPaneBase[]; + private openSidePanel: (headerText: string, panelContent: JSX.Element) => void; + private closeSidePanel: () => void; // Resource Tree public databases: ko.ObservableArray; @@ -278,6 +284,8 @@ export default class Explorer { this.setIsNotificationConsoleExpanded = params?.setIsNotificationConsoleExpanded; this.setNotificationConsoleData = params?.setNotificationConsoleData; this.setInProgressConsoleDataIdToBeDeleted = params?.setInProgressConsoleDataIdToBeDeleted; + this.openSidePanel = params?.openSidePanel; + this.closeSidePanel = params?.closeSidePanel; const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { dataExplorerArea: Constants.Areas.ResourceTree, @@ -423,8 +431,8 @@ export default class Explorer { this.shouldShowShareDialogContents = ko.observable(false); this.shouldShowDataAccessExpiryDialog = ko.observable(false); this.shouldShowContextSwitchPrompt = ko.observable(false); - this.isGalleryPublishEnabled = ko.computed(() => - this.isFeatureEnabled(Constants.Features.enableGalleryPublish) + this.isGalleryPublishEnabled = ko.computed( + () => configContext.ENABLE_GALLERY_PUBLISH || this.isFeatureEnabled(Constants.Features.enableGalleryPublish) ); this.isLinkInjectionEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableLinkInjection) @@ -1889,6 +1897,9 @@ export default class Explorer { if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) { this.isMongoIndexingEnabled(true); } + if (flights.indexOf(Constants.Flights.GalleryPublish) !== -1) { + this.isGalleryPublishEnabled = ko.computed(() => true); + } } public findSelectedCollection(): ViewModels.Collection { @@ -2249,7 +2260,7 @@ export default class Explorer { return Promise.resolve(false); } - public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise { + public async publishNotebook(name: string, content: string | unknown, parentDomElement?: HTMLElement): Promise { if (this.notebookManager) { await this.notebookManager.openPublishNotebookPane( name, @@ -2810,10 +2821,36 @@ export default class Explorer { } } - public async openGallery(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) { + public async openGallery( + selectedTab?: GalleryTab, + notebookUrl?: string, + galleryItem?: IGalleryItem, + isFavorite?: boolean + ) { let title: string = "Gallery"; let hashLocation: string = "gallery"; + const galleryTabOptions: any = { + // GalleryTabOptions + account: userContext.databaseAccount, + container: this, + junoClient: this.notebookManager?.junoClient, + selectedTab: selectedTab || GalleryTab.OfficialSamples, + notebookUrl, + galleryItem, + isFavorite, + // TabOptions + tabKind: ViewModels.CollectionTabKind.Gallery, + title: title, + tabPath: title, + documentClientUtility: null, + isActive: ko.observable(false), + hashLocation: hashLocation, + onUpdateTabsButtons: this.onUpdateTabsButtons, + isTabsContentExpanded: ko.observable(true), + onLoadStartKey: null, + }; + const galleryTabs = this.tabsManager.getTabs( ViewModels.CollectionTabKind.Gallery, (tab) => tab.hashLocation() == hashLocation @@ -2822,31 +2859,12 @@ export default class Explorer { if (galleryTab) { this.tabsManager.activateTab(galleryTab); + (galleryTab as any).reset(galleryTabOptions); } else { if (!this.galleryTab) { this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab"); } - - const newTab = new this.galleryTab.default({ - // GalleryTabOptions - account: userContext.databaseAccount, - container: this, - junoClient: this.notebookManager?.junoClient, - notebookUrl, - galleryItem, - isFavorite, - // TabOptions - tabKind: ViewModels.CollectionTabKind.Gallery, - title: title, - tabPath: title, - documentClientUtility: null, - isActive: ko.observable(false), - hashLocation: hashLocation, - onUpdateTabsButtons: this.onUpdateTabsButtons, - isTabsContentExpanded: ko.observable(true), - onLoadStartKey: null, - }); - + const newTab = new this.galleryTab.default(galleryTabOptions); this.tabsManager.activateNewTab(newTab); } } @@ -3028,4 +3046,17 @@ export default class Explorer { return false; }); } + + public openDeleteCollectionConfirmationPane(): void { + this.isFeatureEnabled(Constants.Features.enableKOPanel) + ? this.deleteCollectionConfirmationPane.open() + : this.openSidePanel( + "Delete Collection", + this.closeSidePanel()} + openNotificationConsole={() => this.expandConsole()} + /> + ); + } } diff --git a/src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent.tsx b/src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent.tsx index 27b4add80..e0810e671 100644 --- a/src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent.tsx +++ b/src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent.tsx @@ -114,6 +114,7 @@ export class NotificationConsoleComponent extends React.Component<
) => this.expandCollapseConsole()} onKeyDown={(event: React.KeyboardEvent) => this.onExpandCollapseKeyPress(event)} diff --git a/src/Explorer/Menus/NotificationConsole/__snapshots__/NotificationConsoleComponent.test.tsx.snap b/src/Explorer/Menus/NotificationConsole/__snapshots__/NotificationConsoleComponent.test.tsx.snap index b50d21678..b00632fdd 100644 --- a/src/Explorer/Menus/NotificationConsole/__snapshots__/NotificationConsoleComponent.test.tsx.snap +++ b/src/Explorer/Menus/NotificationConsole/__snapshots__/NotificationConsoleComponent.test.tsx.snap @@ -6,6 +6,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = ` >
{ - describe("Explorer.isLastCollection()", () => { - let explorer: Explorer; - - beforeEach(() => { - explorer = new Explorer(); - }); - - it("should be true if 1 database and 1 collection", () => { - let database = {} as ViewModels.Database; - database.collections = ko.observableArray([{} as ViewModels.Collection]); - explorer.databases = ko.observableArray([database]); - expect(explorer.isLastCollection()).toBe(true); - }); - - it("should be false if if 1 database and 2 collection", () => { - let database = {} as ViewModels.Database; - database.collections = ko.observableArray([ - {} as ViewModels.Collection, - {} as ViewModels.Collection, - ]); - explorer.databases = ko.observableArray([database]); - expect(explorer.isLastCollection()).toBe(false); - }); - - it("should be false if 2 database and 1 collection each", () => { - let database = {} as ViewModels.Database; - database.collections = ko.observableArray([{} as ViewModels.Collection]); - let database2 = {} as ViewModels.Database; - database2.collections = ko.observableArray([{} as ViewModels.Collection]); - explorer.databases = ko.observableArray([database, database2]); - expect(explorer.isLastCollection()).toBe(false); - }); - - it("should be false if 0 databases", () => { - let database = {} as ViewModels.Database; - explorer.databases = ko.observableArray(); - database.collections = ko.observableArray(); - expect(explorer.isLastCollection()).toBe(false); - }); - }); - - describe("shouldRecordFeedback()", () => { - it("should return true if last collection and database does not have shared throughput else false", () => { - let fakeExplorer = new Explorer(); - fakeExplorer.refreshAllDatabases = () => Q.resolve(); - - let pane = new DeleteCollectionConfirmationPane({ - id: "deletecollectionconfirmationpane", - visible: ko.observable(false), - container: fakeExplorer, - }); - - fakeExplorer.isLastCollection = () => true; - fakeExplorer.isSelectedDatabaseShared = () => false; - expect(pane.shouldRecordFeedback()).toBe(true); - - fakeExplorer.isLastCollection = () => true; - fakeExplorer.isSelectedDatabaseShared = () => true; - expect(pane.shouldRecordFeedback()).toBe(false); - - fakeExplorer.isLastCollection = () => false; - fakeExplorer.isSelectedDatabaseShared = () => false; - expect(pane.shouldRecordFeedback()).toBe(false); - }); - }); - - describe("submit()", () => { - let telemetryProcessorSpy: sinon.SinonSpy; - - beforeEach(() => { - (deleteCollection as jest.Mock).mockResolvedValue(undefined); - telemetryProcessorSpy = sinon.spy(TelemetryProcessor, "trace"); - }); - - afterEach(() => { - telemetryProcessorSpy.restore(); - }); - - it("it should log feedback if last collection and database is not shared", () => { - let selectedCollectionId = "testCol"; - let fakeExplorer = {} as Explorer; - fakeExplorer.findSelectedCollection = () => { - return { - id: ko.observable(selectedCollectionId), - rid: "test", - } as ViewModels.Collection; - }; - fakeExplorer.selectedCollectionId = ko.computed(() => selectedCollectionId); - fakeExplorer.isSelectedDatabaseShared = () => false; - const SubscriptionId = "testId"; - const AccountName = "testAccount"; - fakeExplorer.databaseAccount = ko.observable({ - id: SubscriptionId, - name: AccountName, - } as DataModels.DatabaseAccount); - - fakeExplorer.defaultExperience = ko.observable("DocumentDB"); - fakeExplorer.isPreferredApiCassandra = ko.computed(() => { - return false; - }); - - fakeExplorer.selectedNode = ko.observable(); - fakeExplorer.isLastCollection = () => true; - fakeExplorer.isSelectedDatabaseShared = () => false; - fakeExplorer.refreshAllDatabases = () => Q.resolve(); - - let pane = new DeleteCollectionConfirmationPane({ - id: "deletecollectionconfirmationpane", - visible: ko.observable(false), - container: fakeExplorer as any, - }); - pane.collectionIdConfirmation = ko.observable(selectedCollectionId); - const Feedback = "my feedback"; - pane.containerDeleteFeedback(Feedback); - - return pane.submit().then(() => { - expect(telemetryProcessorSpy.called).toBe(true); - let deleteFeedback = new DeleteFeedback(SubscriptionId, AccountName, DataModels.ApiKind.SQL, Feedback); - expect( - telemetryProcessorSpy.calledWith(Action.DeleteCollection, ActionModifiers.Mark, { - message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)), - }) - ).toBe(true); - }); - }); - }); -}); diff --git a/src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx b/src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx new file mode 100644 index 000000000..5a80c25b0 --- /dev/null +++ b/src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx @@ -0,0 +1,174 @@ +jest.mock("../../Common/dataAccess/deleteCollection"); +jest.mock("../../Shared/Telemetry/TelemetryProcessor"); +import * as ko from "knockout"; +import { ApiKind, DatabaseAccount } from "../../Contracts/DataModels"; +import { Collection, Database } from "../../Contracts/ViewModels"; +import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; +import { mount, ReactWrapper, shallow } from "enzyme"; +import React from "react"; +import DeleteFeedback from "../../Common/DeleteFeedback"; +import Explorer from "../Explorer"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import { TreeNode } from "../../Contracts/ViewModels"; +import { deleteCollection } from "../../Common/dataAccess/deleteCollection"; +import { DeleteCollectionConfirmationPanel } from "./DeleteCollectionConfirmationPanel"; +import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType"; +import { updateUserContext } from "../../UserContext"; + +describe("Delete Collection Confirmation Pane", () => { + describe("Explorer.isLastCollection()", () => { + let explorer: Explorer; + + beforeEach(() => { + explorer = new Explorer(); + }); + + it("should be true if 1 database and 1 collection", () => { + const database = {} as Database; + database.collections = ko.observableArray([{} as Collection]); + explorer.databases = ko.observableArray([database]); + expect(explorer.isLastCollection()).toBe(true); + }); + + it("should be false if if 1 database and 2 collection", () => { + const database = {} as Database; + database.collections = ko.observableArray([{} as Collection, {} as Collection]); + explorer.databases = ko.observableArray([database]); + expect(explorer.isLastCollection()).toBe(false); + }); + + it("should be false if 2 database and 1 collection each", () => { + const database = {} as Database; + database.collections = ko.observableArray([{} as Collection]); + const database2 = {} as Database; + database2.collections = ko.observableArray([{} as Collection]); + explorer.databases = ko.observableArray([database, database2]); + expect(explorer.isLastCollection()).toBe(false); + }); + + it("should be false if 0 databases", () => { + const database = {} as Database; + explorer.databases = ko.observableArray(); + database.collections = ko.observableArray(); + expect(explorer.isLastCollection()).toBe(false); + }); + }); + + describe("shouldRecordFeedback()", () => { + it("should return true if last collection and database does not have shared throughput else false", () => { + const fakeExplorer = new Explorer(); + fakeExplorer.refreshAllDatabases = () => undefined; + fakeExplorer.isLastCollection = () => true; + fakeExplorer.isSelectedDatabaseShared = () => false; + + const props = { + explorer: fakeExplorer, + closePanel: (): void => undefined, + openNotificationConsole: (): void => undefined, + }; + const wrapper = shallow(); + expect(wrapper.exists(".deleteCollectionFeedback")).toBe(true); + + props.explorer.isLastCollection = () => true; + props.explorer.isSelectedDatabaseShared = () => true; + wrapper.setProps(props); + expect(wrapper.exists(".deleteCollectionFeedback")).toBe(false); + + props.explorer.isLastCollection = () => false; + props.explorer.isSelectedDatabaseShared = () => false; + wrapper.setProps(props); + expect(wrapper.exists(".deleteCollectionFeedback")).toBe(false); + }); + }); + + describe("submit()", () => { + let wrapper: ReactWrapper; + const selectedCollectionId = "testCol"; + const databaseId = "testDatabase"; + const fakeExplorer = {} as Explorer; + fakeExplorer.findSelectedCollection = () => { + return { + id: ko.observable(selectedCollectionId), + databaseId, + rid: "test", + } as Collection; + }; + fakeExplorer.selectedCollectionId = ko.computed(() => selectedCollectionId); + fakeExplorer.selectedNode = ko.observable(); + fakeExplorer.refreshAllDatabases = () => undefined; + fakeExplorer.isLastCollection = () => true; + fakeExplorer.isSelectedDatabaseShared = () => false; + + beforeAll(() => { + updateUserContext({ + databaseAccount: { + name: "testDatabaseAccountName", + properties: { + cassandraEndpoint: "testEndpoint", + }, + id: "testDatabaseAccountId", + } as DatabaseAccount, + defaultExperience: DefaultAccountExperienceType.DocumentDB, + }); + (deleteCollection as jest.Mock).mockResolvedValue(undefined); + (TelemetryProcessor.trace as jest.Mock).mockReturnValue(undefined); + }); + + beforeEach(() => { + const props = { + explorer: fakeExplorer, + closePanel: (): void => undefined, + openNotificationConsole: (): void => undefined, + }; + wrapper = mount(); + }); + + it("should call delete collection", () => { + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.exists("#confirmCollectionId")).toBe(true); + wrapper + .find("#confirmCollectionId") + .hostNodes() + .simulate("change", { target: { value: selectedCollectionId } }); + + expect(wrapper.exists("#sidePanelOkButton")).toBe(true); + wrapper.find("#sidePanelOkButton").hostNodes().simulate("click"); + expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId); + + wrapper.unmount(); + }); + + it("should record feedback", async () => { + expect(wrapper.exists("#confirmCollectionId")).toBe(true); + wrapper + .find("#confirmCollectionId") + .hostNodes() + .simulate("change", { target: { value: selectedCollectionId } }); + + expect(wrapper.exists("#deleteCollectionFeedbackInput")).toBe(true); + const feedbackText = "Test delete collection feedback text"; + wrapper + .find("#deleteCollectionFeedbackInput") + .hostNodes() + .simulate("change", { target: { value: feedbackText } }); + + expect(wrapper.exists("#sidePanelOkButton")).toBe(true); + wrapper.find("#sidePanelOkButton").hostNodes().simulate("click"); + expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId); + + const deleteFeedback = new DeleteFeedback( + "testDatabaseAccountId", + "testDatabaseAccountName", + ApiKind.SQL, + feedbackText + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(TelemetryProcessor.trace).toHaveBeenCalledWith(Action.DeleteCollection, ActionModifiers.Mark, { + message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)), + }); + + wrapper.unmount(); + }); + }); +}); diff --git a/src/Explorer/Panes/DeleteCollectionConfirmationPane.ts b/src/Explorer/Panes/DeleteCollectionConfirmationPane.ts index 2000689ec..dd5e91d73 100644 --- a/src/Explorer/Panes/DeleteCollectionConfirmationPane.ts +++ b/src/Explorer/Panes/DeleteCollectionConfirmationPane.ts @@ -1,9 +1,7 @@ import * as ko from "knockout"; -import Q from "q"; import * as ViewModels from "../../Contracts/ViewModels"; import * as Constants from "../../Common/Constants"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; -import { CassandraAPIDataClient } from "../Tables/TableDataClient"; import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import { ContextualPaneBase } from "./ContextualPaneBase"; import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility"; @@ -50,18 +48,7 @@ export default class DeleteCollectionConfirmationPane extends ContextualPaneBase dataExplorerArea: Constants.Areas.ContextualPane, paneTitle: this.title(), }); - let promise: Promise; - if (this.container.isPreferredApiCassandra()) { - promise = ((this.container.tableDataClient).deleteTableOrKeyspace( - this.container.databaseAccount().properties.cassandraEndpoint, - this.container.databaseAccount().id, - `DROP TABLE ${selectedCollection.databaseId}.${selectedCollection.id()};`, - this.container - ) as unknown) as Promise; - } else { - promise = deleteCollection(selectedCollection.databaseId, selectedCollection.id()); - } - return promise.then( + return deleteCollection(selectedCollection.databaseId, selectedCollection.id()).then( () => { this.isExecuting(false); this.close(); diff --git a/src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx b/src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx new file mode 100644 index 000000000..b61ebb074 --- /dev/null +++ b/src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx @@ -0,0 +1,186 @@ +import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import * as React from "react"; +import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; +import { PanelFooterComponent } from "./PanelFooterComponent"; +import { Collection } from "../../Contracts/ViewModels"; +import { Text, TextField } from "office-ui-fabric-react"; +import { userContext } from "../../UserContext"; +import { Areas } from "../../Common/Constants"; +import { deleteCollection } from "../../Common/dataAccess/deleteCollection"; +import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; +import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility"; +import { PanelErrorComponent, PanelErrorProps } from "./PanelErrorComponent"; +import DeleteFeedback from "../../Common/DeleteFeedback"; +import Explorer from "../Explorer"; +import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif"; + +export interface DeleteCollectionConfirmationPanelProps { + explorer: Explorer; + closePanel: () => void; + openNotificationConsole: () => void; +} + +export interface DeleteCollectionConfirmationPanelState { + formError: string; + isExecuting: boolean; +} + +export class DeleteCollectionConfirmationPanel extends React.Component< + DeleteCollectionConfirmationPanelProps, + DeleteCollectionConfirmationPanelState +> { + private inputCollectionName: string; + private deleteCollectionFeedback: string; + + constructor(props: DeleteCollectionConfirmationPanelProps) { + super(props); + + this.state = { + formError: "", + isExecuting: false, + }; + } + + render(): JSX.Element { + return ( +
+ +
+
+ * + Confirm by typing the collection id + { + this.inputCollectionName = newInput; + }} + /> +
+ {this.shouldRecordFeedback() && ( +
+ + Help us improve Azure Cosmos DB! + + + What is the reason why you are deleting this container? + + { + this.deleteCollectionFeedback = newInput; + }} + /> +
+ )} +
+ this.submit()} /> + +
+ ); + } + + private getPanelErrorProps(): PanelErrorProps { + if (this.state.formError) { + return { + isWarning: false, + message: this.state.formError, + showErrorDetails: true, + openNotificationConsole: this.props.openNotificationConsole, + }; + } + + return { + isWarning: true, + showErrorDetails: false, + message: + "Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.", + }; + } + + private shouldRecordFeedback(): boolean { + return this.props.explorer.isLastCollection() && !this.props.explorer.isSelectedDatabaseShared(); + } + + public async submit(): Promise { + const collection = this.props.explorer.findSelectedCollection(); + + if (!collection || this.inputCollectionName !== collection.id()) { + const errorMessage = "Input collection name does not match the selected collection"; + this.setState({ formError: errorMessage }); + NotificationConsoleUtils.logConsoleError(`Error while deleting collection ${collection.id()}: ${errorMessage}`); + return; + } + + this.setState({ formError: "", isExecuting: true }); + + const startKey: number = TelemetryProcessor.traceStart(Action.DeleteCollection, { + databaseAccountName: userContext.databaseAccount?.name, + defaultExperience: userContext.defaultExperience, + collectionId: collection.id(), + dataExplorerArea: Areas.ContextualPane, + paneTitle: "Delete Collection", + }); + + try { + await deleteCollection(collection.databaseId, collection.id()); + + this.setState({ isExecuting: false }); + this.props.explorer.selectedNode(collection.database); + this.props.explorer.tabsManager?.closeTabsByComparator( + (tab) => tab.node?.id() === collection.id() && (tab.node as Collection).databaseId === collection.databaseId + ); + this.props.explorer.refreshAllDatabases(); + + TelemetryProcessor.traceSuccess( + Action.DeleteCollection, + { + databaseAccountName: userContext.databaseAccount?.name, + defaultExperience: userContext.defaultExperience, + collectionId: collection.id(), + dataExplorerArea: Areas.ContextualPane, + paneTitle: "Delete Collection", + }, + startKey + ); + + if (this.shouldRecordFeedback()) { + const deleteFeedback = new DeleteFeedback( + userContext.databaseAccount?.id, + userContext.databaseAccount?.name, + DefaultExperienceUtility.getApiKindFromDefaultExperience(userContext.defaultExperience), + this.deleteCollectionFeedback + ); + + TelemetryProcessor.trace(Action.DeleteCollection, ActionModifiers.Mark, { + message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)), + }); + } + + this.props.closePanel(); + } catch (error) { + const errorMessage = getErrorMessage(error); + this.setState({ formError: errorMessage, isExecuting: false }); + TelemetryProcessor.traceFailure( + Action.DeleteCollection, + { + databaseAccountName: userContext.databaseAccount?.name, + defaultExperience: userContext.defaultExperience, + collectionId: collection.id(), + dataExplorerArea: Areas.ContextualPane, + paneTitle: "Delete Collection", + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey + ); + } + } +} diff --git a/src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts b/src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts index 9d0a1192d..59ea9b82e 100644 --- a/src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts +++ b/src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts @@ -52,81 +52,71 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase { dataExplorerArea: Constants.Areas.ContextualPane, paneTitle: this.title(), }); - // TODO: Should not be a Q promise anymore, but the Cassandra code requires it - let promise: Q.Promise; - if (this.container.isPreferredApiCassandra()) { - promise = (this.container.tableDataClient).deleteTableOrKeyspace( - this.container.databaseAccount().properties.cassandraEndpoint, - this.container.databaseAccount().id, - `DROP KEYSPACE ${selectedDatabase.id()};`, - this.container - ); - } else { - promise = Q(deleteDatabase(selectedDatabase.id())); - } - return promise.then( - () => { - this.isExecuting(false); - this.close(); - this.container.refreshAllDatabases(); - this.container.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id()); - this.container.selectedNode(null); - selectedDatabase - .collections() - .forEach((collection: ViewModels.Collection) => - this.container.tabsManager.closeTabsByComparator( - (tab) => - tab.node?.id() === collection.id() && - (tab.node as ViewModels.Collection).databaseId === collection.databaseId - ) - ); - this.resetData(); - TelemetryProcessor.traceSuccess( - Action.DeleteDatabase, - { - databaseAccountName: this.container.databaseAccount().name, - defaultExperience: this.container.defaultExperience(), - databaseId: selectedDatabase.id(), - dataExplorerArea: Constants.Areas.ContextualPane, - paneTitle: this.title(), - }, - startKey - ); - - if (this.shouldRecordFeedback()) { - let deleteFeedback = new DeleteFeedback( - this.container.databaseAccount().id, - this.container.databaseAccount().name, - DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience()), - this.databaseDeleteFeedback() + return Q( + deleteDatabase(selectedDatabase.id()).then( + () => { + this.isExecuting(false); + this.close(); + this.container.refreshAllDatabases(); + this.container.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id()); + this.container.selectedNode(null); + selectedDatabase + .collections() + .forEach((collection: ViewModels.Collection) => + this.container.tabsManager.closeTabsByComparator( + (tab) => + tab.node?.id() === collection.id() && + (tab.node as ViewModels.Collection).databaseId === collection.databaseId + ) + ); + this.resetData(); + TelemetryProcessor.traceSuccess( + Action.DeleteDatabase, + { + databaseAccountName: this.container.databaseAccount().name, + defaultExperience: this.container.defaultExperience(), + databaseId: selectedDatabase.id(), + dataExplorerArea: Constants.Areas.ContextualPane, + paneTitle: this.title(), + }, + startKey ); - TelemetryProcessor.trace(Action.DeleteDatabase, ActionModifiers.Mark, { - message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)), - }); + if (this.shouldRecordFeedback()) { + let deleteFeedback = new DeleteFeedback( + this.container.databaseAccount().id, + this.container.databaseAccount().name, + DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience()), + this.databaseDeleteFeedback() + ); - this.databaseDeleteFeedback(""); + TelemetryProcessor.trace(Action.DeleteDatabase, ActionModifiers.Mark, { + message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)), + }); + + this.databaseDeleteFeedback(""); + } + }, + (error: any) => { + this.isExecuting(false); + const errorMessage = getErrorMessage(error); + this.formErrors(errorMessage); + this.formErrorsDetails(errorMessage); + TelemetryProcessor.traceFailure( + Action.DeleteDatabase, + { + databaseAccountName: this.container.databaseAccount().name, + defaultExperience: this.container.defaultExperience(), + databaseId: selectedDatabase.id(), + dataExplorerArea: Constants.Areas.ContextualPane, + paneTitle: this.title(), + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey + ); } - }, - (error: any) => { - this.isExecuting(false); - const errorMessage = getErrorMessage(error); - this.formErrors(errorMessage); - this.formErrorsDetails(errorMessage); - TelemetryProcessor.traceFailure( - Action.DeleteDatabase, - { - databaseAccountName: this.container.databaseAccount().name, - defaultExperience: this.container.defaultExperience(), - databaseId: selectedDatabase.id(), - dataExplorerArea: Constants.Areas.ContextualPane, - paneTitle: this.title(), - error: errorMessage, - errorStack: getErrorStack(error), - }, - startKey - ); - } + ) ); } diff --git a/src/Explorer/Panes/PanelComponent.less b/src/Explorer/Panes/PanelComponent.less new file mode 100644 index 000000000..820a9f1c9 --- /dev/null +++ b/src/Explorer/Panes/PanelComponent.less @@ -0,0 +1,57 @@ +@import "../../../less/Common/Constants"; + +.panelContentContainer { + display: flex; + flex-direction: column; + height: 100%; + + .panelMainContent { + flex-grow: 1; + } +} + +.panelHeader { + color: @BaseDark; + font-size: @largeFontSize; + font-weight: 400; +} + +.panelWarningErrorContainer { + background-color: @BaseLow; + padding: @DefaultSpace; + display: inline-flex; + margin-bottom: 24px; + + .panelWarningIcon { + font-size: @WarningErrorIconSize; + width: @WarningErrorIconSize; + margin: auto 0 auto @SmallSpace; + color: @WarningIconColor; + } + + .panelErrorIcon { + font-size: @WarningErrorIconSize; + width: @WarningErrorIconSize; + margin: auto 0 auto @SmallSpace; + color: @ErrorIconColor; + } + + .panelWarningErrorDetailsLinkContainer { + display: flex; + flex-direction: column; + padding-left: @MediumSpace; + + .paneErrorLink { + cursor: pointer; + font-size: @mediumFontSize; + } + } +} + +.panelFooter button { + height: 30px; +} + +.deleteCollectionFeedback { + margin-top: 12px; +} diff --git a/src/Explorer/Panes/PanelContainerComponent.test.tsx b/src/Explorer/Panes/PanelContainerComponent.test.tsx new file mode 100644 index 000000000..fb5f8e62c --- /dev/null +++ b/src/Explorer/Panes/PanelContainerComponent.test.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { PanelContainerComponent, PanelContainerProps } from "./PanelContainerComponent"; + +describe("PaneContainerComponent test", () => { + it("should render with panel content and header", () => { + const panelContainerProps: PanelContainerProps = { + headerText: "test", + panelContent:
, + isOpen: true, + isConsoleExpanded: false, + closePanel: undefined, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should render nothing if content is undefined", () => { + const panelContainerProps: PanelContainerProps = { + headerText: "test", + panelContent: undefined, + isOpen: true, + isConsoleExpanded: false, + closePanel: undefined, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("should be resize if notification console is expanded", () => { + const panelContainerProps: PanelContainerProps = { + headerText: "test", + panelContent:
, + isOpen: true, + isConsoleExpanded: true, + closePanel: undefined, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/Panes/PanelContainerComponent.tsx b/src/Explorer/Panes/PanelContainerComponent.tsx new file mode 100644 index 000000000..17d1282b2 --- /dev/null +++ b/src/Explorer/Panes/PanelContainerComponent.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { Panel, PanelType } from "office-ui-fabric-react"; + +export interface PanelContainerProps { + headerText: string; + panelContent: JSX.Element; + isConsoleExpanded: boolean; + isOpen: boolean; + closePanel: () => void; +} + +export class PanelContainerComponent extends React.Component { + private static readonly consoleHeaderHeight = 32; + private static readonly consoleContentHeight = 220; + + render(): JSX.Element { + if (!this.props.panelContent) { + return <>; + } + + return ( + + {this.props.panelContent} + + ); + } + + private onDissmiss = (ev?: React.SyntheticEvent): void => { + if ((ev.target as HTMLElement).id === "notificationConsoleHeader") { + ev.preventDefault(); + } else { + this.props.closePanel(); + } + }; + + private getPanelHeight = (): string => { + const consoleHeight = this.props.isConsoleExpanded + ? PanelContainerComponent.consoleContentHeight + PanelContainerComponent.consoleHeaderHeight + : PanelContainerComponent.consoleHeaderHeight; + const panelHeight = window.innerHeight - consoleHeight; + return panelHeight + "px"; + }; +} diff --git a/src/Explorer/Panes/PanelErrorComponent.tsx b/src/Explorer/Panes/PanelErrorComponent.tsx new file mode 100644 index 000000000..dcd9e7648 --- /dev/null +++ b/src/Explorer/Panes/PanelErrorComponent.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Icon, Text } from "office-ui-fabric-react"; + +export interface PanelErrorProps { + message: string; + isWarning: boolean; + showErrorDetails: boolean; + openNotificationConsole?: () => void; +} + +export const PanelErrorComponent: React.FunctionComponent = (props: PanelErrorProps): JSX.Element => ( +
+ {props.isWarning ? ( + + ) : ( + + )} + + + {props.message} + + {props.showErrorDetails && ( + + More details + + )} + +
+); diff --git a/src/Explorer/Panes/PanelFooterComponent.tsx b/src/Explorer/Panes/PanelFooterComponent.tsx new file mode 100644 index 000000000..cbe3adab7 --- /dev/null +++ b/src/Explorer/Panes/PanelFooterComponent.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { PrimaryButton } from "office-ui-fabric-react"; + +export interface PanelFooterProps { + buttonLabel: string; + onOKButtonClicked: () => void; +} + +export const PanelFooterComponent: React.FunctionComponent = ( + props: PanelFooterProps +): JSX.Element => ( +
+ props.onOKButtonClicked()} /> +
+); diff --git a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx index 55a92fb84..5c1313020 100644 --- a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx @@ -10,7 +10,11 @@ import { ImmutableNotebook } from "@nteract/commutable/src"; import { toJS } from "@nteract/commutable"; import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent"; import { HttpStatusCodes } from "../../Common/Constants"; -import { handleError, getErrorMessage } from "../../Common/ErrorHandlingUtils"; +import { handleError, getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; +import { GalleryTab } from "../Controls/NotebookGallery/GalleryViewerComponent"; +import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor"; +import { Action } from "../../Shared/Telemetry/TelemetryConstants"; +import { FileSystemUtil } from "../Notebook/FileSystemUtil"; export class PublishNotebookPaneAdapter implements ReactAdapter { parameters: ko.Observable; @@ -66,7 +70,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { onChangeDescription: (newValue: string) => (this.description = newValue), onChangeTags: (newValue: string) => (this.tags = newValue), onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue), - onError: this.createFormErrorForLargeImageSelection, + onError: this.createFormError, clearFormError: this.clearFormError, }; @@ -140,10 +144,21 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { this.isExecuting = true; this.triggerRender(); + let startKey: number; + + if (!this.name || !this.description || !this.author || !this.imageSrc) { + const formError = `Failed to publish ${this.name} to gallery`; + const formErrorDetail = "Name, description, author and cover image are required"; + this.createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit"); + this.isExecuting = false; + return; + } + try { - if (!this.name || !this.description || !this.author) { - throw new Error("Name, description, and author are required"); - } + startKey = traceStart(Action.NotebooksGalleryPublish, { + databaseAccountName: this.container.databaseAccount()?.name, + defaultExperience: this.container.defaultExperience(), + }); const response = await this.junoClient.publishNotebook( this.name, @@ -157,17 +172,43 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { const data = response.data; if (data) { + let isPublishPending = false; + if (data.pendingScanJobIds?.length > 0) { + isPublishPending = true; NotificationConsoleUtils.logConsoleInfo( `Content of ${this.name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).` ); } else { NotificationConsoleUtils.logConsoleInfo(`Published ${this.name} to gallery`); + this.container.openGallery(GalleryTab.Published); } + + traceSuccess( + Action.NotebooksGalleryPublish, + { + databaseAccountName: this.container.databaseAccount()?.name, + defaultExperience: this.container.defaultExperience(), + notebookId: data.id, + isPublishPending, + }, + startKey + ); } } catch (error) { + traceFailure( + Action.NotebooksGalleryPublish, + { + databaseAccountName: this.container.databaseAccount()?.name, + defaultExperience: this.container.defaultExperience(), + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }, + startKey + ); + const errorMessage = getErrorMessage(error); - this.formError = `Failed to publish ${this.name} to gallery`; + this.formError = `Failed to publish ${FileSystemUtil.stripExtension(this.name, "ipynb")} to gallery`; this.formErrorDetail = `${errorMessage}`; handleError(errorMessage, "PublishNotebookPaneAdapter/submit", this.formError); return; @@ -180,7 +221,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { this.close(); } - private createFormErrorForLargeImageSelection = (formError: string, formErrorDetail: string, area: string): void => { + private createFormError = (formError: string, formErrorDetail: string, area: string): void => { this.formError = formError; this.formErrorDetail = formErrorDetail; handleError(formErrorDetail, area, formError); diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx index a0a81abb7..559bf3b09 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx @@ -14,7 +14,7 @@ export interface PublishNotebookPaneProps { notebookAuthor: string; notebookCreatedDate: string; notebookObject: ImmutableNotebook; - notebookParentDomElement: HTMLElement; + notebookParentDomElement?: HTMLElement; onChangeName: (newValue: string) => void; onChangeDescription: (newValue: string) => void; onChangeTags: (newValue: string) => void; @@ -54,7 +54,7 @@ export class PublishNotebookPaneComponent extends React.Component { this.props.onChangeImageSrc(newValue); this.setState({ imageSrc: newValue }); @@ -140,17 +141,23 @@ export class PublishNotebookPaneComponent extends React.Component ({ text: value, key: value })), + options: options.map((value: string) => ({ text: value, key: value })), onChange: async (event, options) => { + this.setState({ imageSrc: undefined }); + this.props.onChangeImageSrc(undefined); this.props.clearFormError(); if (options.text === ImageTypes.TakeScreenshot) { try { @@ -172,11 +179,12 @@ export class PublishNotebookPaneComponent extends React.Component { - this.props.onChangeName(newValue); - this.setState({ notebookName: newValue }); + const notebookName = newValue + ".ipynb"; + this.props.onChangeName(notebookName); + this.setState({ notebookName }); }, }; @@ -293,16 +301,16 @@ export class PublishNotebookPaneComponent extends React.Component +
+ +
+ + + +  + + + + + + + Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources. + + + +
+
+
+
+ + * + + + + Confirm by typing the collection id + + + + +
+
+
+ +
+
+
+
+
+
+
+ + + Help us improve Azure Cosmos DB! + + + + + What is the reason why you are deleting this container? + + + + +
+
+
+
Name