Merge branch 'users/fnbalaji/PortalChangesForDGW' of https://github.com/Azure/cosmos-explorer into users/fnbalaji/PortalChangesForDGW

This commit is contained in:
Balaji Sridharan 2021-02-15 23:49:54 -08:00
commit a09bcc7197
88 changed files with 4982 additions and 963 deletions

View File

@ -87,7 +87,7 @@ src/Explorer/DataSamples/ContainerSampleGenerator.test.ts
src/Explorer/DataSamples/ContainerSampleGenerator.ts src/Explorer/DataSamples/ContainerSampleGenerator.ts
src/Explorer/DataSamples/DataSamplesUtil.test.ts src/Explorer/DataSamples/DataSamplesUtil.test.ts
src/Explorer/DataSamples/DataSamplesUtil.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.test.ts
src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts
src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts

View File

@ -166,6 +166,8 @@ jobs:
PORTAL_RUNNER_RESOURCE_GROUP: ${{ secrets.PORTAL_RUNNER_RESOURCE_GROUP }} PORTAL_RUNNER_RESOURCE_GROUP: ${{ secrets.PORTAL_RUNNER_RESOURCE_GROUP }}
PORTAL_RUNNER_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT }} PORTAL_RUNNER_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT }}
PORTAL_RUNNER_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY }} 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_TENANT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_TENANT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }} NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }} NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }}

View File

@ -1,3 +1,4 @@
{ {
"JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com" "JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com",
"ENABLE_GALLERY_PUBLISH": true
} }

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.31449 2.01439L4.00103 5.31963L3.26105 4.57965L7.8407 0L12.4203 4.57965L11.6804 5.31963L8.36691 2.01439V12.8428H7.31449V2.01439ZM13.629 12.8428H14.6814V16H1V12.8428H2.05242V14.9476H13.629V12.8428Z" fill="#0078D4"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@ -57,6 +57,13 @@
@FocusColor: #605e5c; @FocusColor: #605e5c;
@GalleryBackgroundColor: #fdfdfd;
//Icons
@InfoIconColor: #0072c6;
@WarningIconColor: #db7500;
@ErrorIconColor: #b91f26;
/****************************************************************************** /******************************************************************************
METRICS METRICS
/******************************************************************************/ /******************************************************************************/

View File

@ -1523,6 +1523,21 @@ p {
.tooltipVisible(); .tooltipVisible();
} }
.inputTooltip {
.inputTooltip();
}
.inputTooltip .inputTooltipText {
top: -68px;
.inputTooltipText();
}
.inputTooltip .inputTooltipText::after {
border-width: @MediumSpace @MediumSpace 0 @MediumSpace;
top: 55px;
.inputTooltipTextAfter();
}
.infoTooltip a { .infoTooltip a {
color: @AccentHigh; color: @AccentHigh;
} }
@ -3028,3 +3043,45 @@ settings-pane {
.collapsibleSection :hover { .collapsibleSection :hover {
cursor: pointer; 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;
}
}

View File

@ -119,7 +119,9 @@ export class Features {
public static readonly enableSchema = "enableschema"; public static readonly enableSchema = "enableschema";
public static readonly enableSDKoperations = "enablesdkoperations"; public static readonly enableSDKoperations = "enablesdkoperations";
public static readonly showMinRUSurvey = "showminrusurvey"; public static readonly showMinRUSurvey = "showminrusurvey";
public static readonly enableDatabaseSettingsTabV1 = "enabledbsettingsv1";
public static readonly selfServeType = "selfservetype"; public static readonly selfServeType = "selfservetype";
public static readonly enableKOPanel = "enablekopanel";
} }
// flight names returned from the portal are always lowercase // flight names returned from the portal are always lowercase
@ -128,6 +130,7 @@ export class Flights {
public static readonly MongoIndexEditor = "mongoindexeditor"; public static readonly MongoIndexEditor = "mongoindexeditor";
public static readonly MongoIndexing = "mongoindexing"; public static readonly MongoIndexing = "mongoindexing";
public static readonly AutoscaleTest = "autoscaletest"; public static readonly AutoscaleTest = "autoscaletest";
public static readonly GalleryPublish = "gallerypublish";
} }
export class AfecFeatures { export class AfecFeatures {

View File

@ -76,7 +76,7 @@ export const getCollectionUsageSizeInKB = async (databaseName: string, container
return dataUsageSizeInKb + indexUsageSizeInKb; return dataUsageSizeInKb + indexUsageSizeInKb;
} catch (error) { } catch (error) {
handleError(error, "getCollectionUsageSize"); handleError(error, "getCollectionUsageSize");
throw error; return undefined;
} }
}; };

View File

@ -26,6 +26,7 @@ export interface ConfigContext {
GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it. GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it.
hostedExplorerURL: string; hostedExplorerURL: string;
armAPIVersion?: string; armAPIVersion?: string;
ENABLE_GALLERY_PUBLISH?: boolean;
} }
// Default configuration // Default configuration
@ -79,7 +80,11 @@ if (process.env.NODE_ENV === "development") {
export async function initializeConfiguration(): Promise<ConfigContext> { export async function initializeConfiguration(): Promise<ConfigContext> {
try { 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) { if (response.status === 200) {
try { try {
const { allowedParentFrameOrigins, ...externalConfig } = await response.json(); const { allowedParentFrameOrigins, ...externalConfig } = await response.json();

View File

@ -91,6 +91,7 @@ export interface Database extends TreeNode {
onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void; onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void;
onSettingsClick: () => void; onSettingsClick: () => void;
loadOffer(): Promise<void>; loadOffer(): Promise<void>;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
} }
export interface CollectionBase extends TreeNode { export interface CollectionBase extends TreeNode {
@ -137,7 +138,6 @@ export interface Collection extends CollectionBase {
openTab(): void; openTab(): void;
onSettingsClick: () => Promise<void>; onSettingsClick: () => Promise<void>;
onDeleteCollectionContextMenuClick(source: Collection, event: MouseEvent): void;
onNewGraphClick(): void; onNewGraphClick(): void;
onNewMongoQueryClick(source: any, event: MouseEvent, queryText?: string): void; onNewMongoQueryClick(source: any, event: MouseEvent, queryText?: string): void;
@ -178,6 +178,7 @@ export interface Collection extends CollectionBase {
uploadFiles(fileList: FileList): Promise<UploadDetails>; uploadFiles(fileList: FileList): Promise<UploadDetails>;
getLabel(): string; getLabel(): string;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
} }
/** /**
@ -292,10 +293,6 @@ export interface DocumentsTabOptions extends TabOptions {
resourceTokenPartitionKey?: string; resourceTokenPartitionKey?: string;
} }
export interface SettingsTabV2Options extends TabOptions {
getPendingNotification: Promise<DataModels.Notification>;
}
export interface ConflictsTabOptions extends TabOptions { export interface ConflictsTabOptions extends TabOptions {
partitionKey: DataModels.PartitionKey; partitionKey: DataModels.PartitionKey;
conflictIds: ko.ObservableArray<ConflictId>; conflictIds: ko.ObservableArray<ConflictId>;
@ -362,7 +359,8 @@ export enum CollectionTabKind {
Gallery = 17, Gallery = 17,
NotebookViewer = 18, NotebookViewer = 18,
Schema = 19, Schema = 19,
SettingsV2 = 20, CollectionSettingsV2 = 20,
DatabaseSettingsV2 = 21,
} }
export enum TerminalKind { export enum TerminalKind {

View File

@ -45,7 +45,8 @@ describe("Component Registerer", () => {
}); });
it("should register settings-tab-v2 component", () => { 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", () => { it("should register query-tab component", () => {

View File

@ -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("stored-procedure-tab", new TabComponents.StoredProcedureTab());
ko.components.register("trigger-tab", new TabComponents.TriggerTab()); ko.components.register("trigger-tab", new TabComponents.TriggerTab());
ko.components.register("user-defined-function-tab", new TabComponents.UserDefinedFunctionTab()); 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("query-tab", new TabComponents.QueryTab());
ko.components.register("tables-query-tab", new TabComponents.QueryTablesTab()); ko.components.register("tables-query-tab", new TabComponents.QueryTablesTab());
ko.components.register("graph-tab", new TabComponents.GraphTab()); ko.components.register("graph-tab", new TabComponents.GraphTab());
@ -45,6 +45,7 @@ ko.components.register("notebook-viewer-tab", new TabComponents.NotebookViewerTa
// Database Tabs // Database Tabs
ko.components.register("database-settings-tab", new TabComponents.DatabaseSettingsTab()); ko.components.register("database-settings-tab", new TabComponents.DatabaseSettingsTab());
ko.components.register("database-settings-tab-v2", new TabComponents.SettingsTabV2());
// Panes // Panes
ko.components.register("add-database-pane", new PaneComponents.AddDatabasePaneComponent()); ko.components.register("add-database-pane", new PaneComponents.AddDatabasePaneComponent());

View File

@ -112,10 +112,7 @@ export class ResourceTreeContextMenuButtonFactory {
items.push({ items.push({
iconSrc: DeleteCollectionIcon, iconSrc: DeleteCollectionIcon,
onClick: () => { onClick: () => container.openDeleteCollectionConfirmationPane(),
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
selectedCollection && selectedCollection.onDeleteCollectionContextMenuClick(selectedCollection, null);
},
label: container.deleteCollectionText(), label: container.deleteCollectionText(),
styleClass: "deleteCollectionMenuItem", styleClass: "deleteCollectionMenuItem",
}); });

View File

@ -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 { IButtonProps, PrimaryButton, DefaultButton } from "office-ui-fabric-react/lib/Button";
import { ITextFieldProps, TextField } from "office-ui-fabric-react/lib/TextField"; import { ITextFieldProps, TextField } from "office-ui-fabric-react/lib/TextField";
import { Link } from "office-ui-fabric-react/lib/Link"; 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 { export interface TextFieldProps extends ITextFieldProps {
label: string; label: string;
@ -27,6 +33,7 @@ export interface DialogProps {
choiceGroupProps?: IChoiceGroupProps; choiceGroupProps?: IChoiceGroupProps;
textFieldProps?: TextFieldProps; textFieldProps?: TextFieldProps;
linkProps?: LinkProps; linkProps?: LinkProps;
progressIndicatorProps?: IProgressIndicatorProps;
primaryButtonText: string; primaryButtonText: string;
secondaryButtonText: string; secondaryButtonText: string;
onPrimaryButtonClick: () => void; onPrimaryButtonClick: () => void;
@ -62,13 +69,14 @@ export class DialogComponent extends React.Component<DialogProps, {}> {
showCloseButton: this.props.showCloseButton || false, showCloseButton: this.props.showCloseButton || false,
onDismiss: this.props.onDismiss, onDismiss: this.props.onDismiss,
}, },
modalProps: { isBlocking: this.props.isModal }, modalProps: { isBlocking: this.props.isModal, isDarkOverlay: false },
minWidth: DIALOG_MIN_WIDTH, minWidth: DIALOG_MIN_WIDTH,
maxWidth: DIALOG_MAX_WIDTH, maxWidth: DIALOG_MAX_WIDTH,
}; };
const choiceGroupProps: IChoiceGroupProps = this.props.choiceGroupProps; const choiceGroupProps: IChoiceGroupProps = this.props.choiceGroupProps;
const textFieldProps: ITextFieldProps = this.props.textFieldProps; const textFieldProps: ITextFieldProps = this.props.textFieldProps;
const linkProps: LinkProps = this.props.linkProps; const linkProps: LinkProps = this.props.linkProps;
const progressIndicatorProps: IProgressIndicatorProps = this.props.progressIndicatorProps;
const primaryButtonProps: IButtonProps = { const primaryButtonProps: IButtonProps = {
text: this.props.primaryButtonText, text: this.props.primaryButtonText,
disabled: this.props.primaryButtonDisabled || false, disabled: this.props.primaryButtonDisabled || false,
@ -91,6 +99,7 @@ export class DialogComponent extends React.Component<DialogProps, {}> {
{linkProps.linkText} <FontIcon iconName="NavigateExternalInline" /> {linkProps.linkText} <FontIcon iconName="NavigateExternalInline" />
</Link> </Link>
)} )}
{progressIndicatorProps && <ProgressIndicator {...progressIndicatorProps} />}
<DialogFooter> <DialogFooter>
<PrimaryButton {...primaryButtonProps} /> <PrimaryButton {...primaryButtonProps} />
{secondaryButtonProps && <DefaultButton {...secondaryButtonProps} />} {secondaryButtonProps && <DefaultButton {...secondaryButtonProps} />}

View File

@ -18,7 +18,6 @@ import * as React from "react";
import { IGalleryItem } from "../../../../Juno/JunoClient"; import { IGalleryItem } from "../../../../Juno/JunoClient";
import { FileSystemUtil } from "../../../Notebook/FileSystemUtil"; import { FileSystemUtil } from "../../../Notebook/FileSystemUtil";
import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg"; import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg";
import { StyleConstants } from "../../../../Common/Constants";
export interface GalleryCardComponentProps { export interface GalleryCardComponentProps {
data: IGalleryItem; data: IGalleryItem;
@ -38,7 +37,7 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
private static readonly cardImageHeight = 144; private static readonly cardImageHeight = 144;
public static readonly cardHeightToWidthRatio = public static readonly cardHeightToWidthRatio =
GalleryCardComponent.cardImageHeight / GalleryCardComponent.CARD_WIDTH; GalleryCardComponent.cardImageHeight / GalleryCardComponent.CARD_WIDTH;
private static readonly cardDescriptionMaxChars = 88; private static readonly cardDescriptionMaxChars = 80;
private static readonly cardItemGapBig = 10; private static readonly cardItemGapBig = 10;
private static readonly cardItemGapSmall = 8; private static readonly cardItemGapSmall = 8;
@ -54,6 +53,7 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
return ( return (
<Card <Card
style={{ background: "white" }}
aria-label={cardTitle} aria-label={cardTitle}
data-is-focusable="true" data-is-focusable="true"
tokens={{ width: GalleryCardComponent.CARD_WIDTH, childrenGap: 0 }} tokens={{ width: GalleryCardComponent.CARD_WIDTH, childrenGap: 0 }}
@ -79,12 +79,16 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
<Card.Section styles={{ root: { padding: GalleryCardComponent.cardItemGapBig } }}> <Card.Section styles={{ root: { padding: GalleryCardComponent.cardItemGapBig } }}>
<Text variant="small" nowrap> <Text variant="small" nowrap>
{this.props.data.tags?.map((tag, index, array) => ( {this.props.data.tags ? (
this.props.data.tags.map((tag, index, array) => (
<span key={tag}> <span key={tag}>
<Link onClick={(event) => this.onClick(event, () => this.props.onTagClick(tag))}>{tag}</Link> <Link onClick={(event) => this.onClick(event, () => this.props.onTagClick(tag))}>{tag}</Link>
{index === array.length - 1 ? <></> : ", "} {index === array.length - 1 ? <></> : ", "}
</span> </span>
))} ))
) : (
<br />
)}
</Text> </Text>
<Text <Text
@ -101,13 +105,14 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
</Text> </Text>
<Text variant="small" styles={{ root: { height: 36 } }}> <Text variant="small" styles={{ root: { height: 36 } }}>
{this.props.data.description.substr(0, GalleryCardComponent.cardDescriptionMaxChars)} {this.renderTruncatedDescription()}
</Text> </Text>
<span> <span>
{this.generateIconText("RedEye", this.props.data.views.toString())} {this.props.data.views !== undefined && this.generateIconText("RedEye", this.props.data.views.toString())}
{this.generateIconText("Download", this.props.data.downloads.toString())} {this.props.data.downloads !== undefined &&
{this.props.isFavorite !== undefined && this.generateIconText("Download", this.props.data.downloads.toString())}
{this.props.data.favorites !== undefined &&
this.generateIconText("Heart", this.props.data.favorites.toString())} this.generateIconText("Heart", this.props.data.favorites.toString())}
</span> </span>
</Card.Section> </Card.Section>
@ -127,7 +132,7 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
{this.props.isFavorite !== undefined && {this.props.isFavorite !== undefined &&
this.generateIconButtonWithTooltip( this.generateIconButtonWithTooltip(
this.props.isFavorite ? "HeartFill" : "Heart", this.props.isFavorite ? "HeartFill" : "Heart",
this.props.isFavorite ? "Unlike" : "Like", this.props.isFavorite ? "Unfavorite" : "Favorite",
"left", "left",
this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick
)} )}
@ -144,12 +149,17 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
); );
} }
private renderTruncatedDescription = (): string => {
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 => { private generateIconText = (iconName: string, text: string): JSX.Element => {
return ( return (
<Text <Text variant="tiny" styles={{ root: { color: "#605E5C", paddingRight: GalleryCardComponent.cardItemGapSmall } }}>
variant="tiny"
styles={{ root: { color: StyleConstants.BaseMedium, paddingRight: GalleryCardComponent.cardItemGapSmall } }}
>
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} /> {text} <Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} /> {text}
</Text> </Text>
); );

View File

@ -5,6 +5,11 @@ exports[`GalleryCardComponent renders 1`] = `
aria-label="name" aria-label="name"
data-is-focusable="true" data-is-focusable="true"
onClick={[Function]} onClick={[Function]}
style={
Object {
"background": "white",
}
}
tokens={ tokens={
Object { Object {
"childrenGap": 0, "childrenGap": 0,
@ -88,7 +93,7 @@ exports[`GalleryCardComponent renders 1`] = `
styles={ styles={
Object { Object {
"root": Object { "root": Object {
"color": undefined, "color": "#605E5C",
"paddingRight": 8, "paddingRight": 8,
}, },
} }
@ -112,7 +117,7 @@ exports[`GalleryCardComponent renders 1`] = `
styles={ styles={
Object { Object {
"root": Object { "root": Object {
"color": undefined, "color": "#605E5C",
"paddingRight": 8, "paddingRight": 8,
}, },
} }
@ -136,7 +141,7 @@ exports[`GalleryCardComponent renders 1`] = `
styles={ styles={
Object { Object {
"root": Object { "root": Object {
"color": undefined, "color": "#605E5C",
"paddingRight": 8, "paddingRight": 8,
}, },
} }
@ -185,7 +190,7 @@ exports[`GalleryCardComponent renders 1`] = `
"gapSpace": 0, "gapSpace": 0,
} }
} }
content="Like" content="Favorite"
id="TooltipHost-IconButton-Heart" id="TooltipHost-IconButton-Heart"
styles={ styles={
Object { Object {
@ -197,14 +202,14 @@ exports[`GalleryCardComponent renders 1`] = `
} }
> >
<CustomizedIconButton <CustomizedIconButton
ariaLabel="Like" ariaLabel="Favorite"
iconProps={ iconProps={
Object { Object {
"iconName": "Heart", "iconName": "Heart",
} }
} }
onClick={[Function]} onClick={[Function]}
title="Like" title="Favorite"
/> />
</StyledTooltipHostBase> </StyledTooltipHostBase>
<StyledTooltipHostBase <StyledTooltipHostBase

View File

@ -2,7 +2,9 @@ import * as React from "react";
import { JunoClient } from "../../../Juno/JunoClient"; import { JunoClient } from "../../../Juno/JunoClient";
import { HttpStatusCodes, CodeOfConductEndpoints } from "../../../Common/Constants"; import { HttpStatusCodes, CodeOfConductEndpoints } from "../../../Common/Constants";
import { Stack, Text, Checkbox, PrimaryButton, Link } from "office-ui-fabric-react"; import { Stack, Text, Checkbox, PrimaryButton, Link } from "office-ui-fabric-react";
import { handleError } from "../../../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack, handleError } from "../../../Common/ErrorHandlingUtils";
import { trace, traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
export interface CodeOfConductComponentProps { export interface CodeOfConductComponentProps {
junoClient: JunoClient; junoClient: JunoClient;
@ -14,11 +16,11 @@ interface CodeOfConductComponentState {
} }
export class CodeOfConductComponent extends React.Component<CodeOfConductComponentProps, CodeOfConductComponentState> { export class CodeOfConductComponent extends React.Component<CodeOfConductComponentProps, CodeOfConductComponentState> {
private viewCodeOfConductTraced: boolean;
private descriptionPara1: string; private descriptionPara1: string;
private descriptionPara2: string; private descriptionPara2: string;
private descriptionPara3: string; private descriptionPara3: string;
private link1: { label: string; url: string }; private link1: { label: string; url: string };
private link2: { label: string; url: string };
constructor(props: CodeOfConductComponentProps) { constructor(props: CodeOfConductComponentProps) {
super(props); super(props);
@ -27,23 +29,34 @@ export class CodeOfConductComponent extends React.Component<CodeOfConductCompone
readCodeOfConduct: false, readCodeOfConduct: false,
}; };
this.descriptionPara1 = "Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement"; this.descriptionPara1 = "Azure Cosmos DB Notebook Gallery - Code of Conduct";
this.descriptionPara2 = this.descriptionPara2 = "The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.";
"Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB."; this.descriptionPara3 = "In order to view and publish your samples to the gallery, you must accept the ";
this.descriptionPara3 = "In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the "; this.link1 = { label: "code of conduct.", url: CodeOfConductEndpoints.codeOfConduct };
this.link1 = { label: "code of conduct", url: CodeOfConductEndpoints.codeOfConduct };
this.link2 = { label: "privacy statement", url: CodeOfConductEndpoints.privacyStatement };
} }
private async acceptCodeOfConduct(): Promise<void> { private async acceptCodeOfConduct(): Promise<void> {
const startKey = traceStart(Action.NotebooksGalleryAcceptCodeOfConduct);
try { try {
const response = await this.props.junoClient.acceptCodeOfConduct(); const response = await this.props.junoClient.acceptCodeOfConduct();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`); throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
} }
traceSuccess(Action.NotebooksGalleryAcceptCodeOfConduct, startKey);
this.props.onAcceptCodeOfConduct(response.data); this.props.onAcceptCodeOfConduct(response.data);
} catch (error) { } catch (error) {
traceFailure(
Action.NotebooksGalleryAcceptCodeOfConduct,
{
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
handleError(error, "CodeOfConductComponent/acceptCodeOfConduct", "Failed to accept code of conduct"); handleError(error, "CodeOfConductComponent/acceptCodeOfConduct", "Failed to accept code of conduct");
} }
} }
@ -53,6 +66,11 @@ export class CodeOfConductComponent extends React.Component<CodeOfConductCompone
}; };
public render(): JSX.Element { public render(): JSX.Element {
if (!this.viewCodeOfConductTraced) {
this.viewCodeOfConductTraced = true;
trace(Action.NotebooksGalleryViewCodeOfConduct);
}
return ( return (
<Stack tokens={{ childrenGap: 20 }}> <Stack tokens={{ childrenGap: 20 }}>
<Stack.Item> <Stack.Item>
@ -69,10 +87,6 @@ export class CodeOfConductComponent extends React.Component<CodeOfConductCompone
<Link href={this.link1.url} target="_blank"> <Link href={this.link1.url} target="_blank">
{this.link1.label} {this.link1.label}
</Link> </Link>
{" and "}
<Link href={this.link2.url} target="_blank">
{this.link2.label}
</Link>
</Text> </Text>
</Stack.Item> </Stack.Item>
@ -87,7 +101,7 @@ export class CodeOfConductComponent extends React.Component<CodeOfConductCompone
fontSize: 12, fontSize: 12,
}, },
}} }}
label="I have read and accepted the code of conduct and privacy statement" label="I have read and accept the code of conduct."
onChange={this.onChangeCheckbox} onChange={this.onChangeCheckbox}
/> />
</Stack.Item> </Stack.Item>

View File

@ -7,6 +7,7 @@ import Explorer from "../../Explorer";
export interface GalleryAndNotebookViewerComponentProps { export interface GalleryAndNotebookViewerComponentProps {
container?: Explorer; container?: Explorer;
isGalleryPublishEnabled: boolean;
junoClient: JunoClient; junoClient: JunoClient;
notebookUrl?: string; notebookUrl?: string;
galleryItem?: IGalleryItem; galleryItem?: IGalleryItem;
@ -60,6 +61,7 @@ export class GalleryAndNotebookViewerComponent extends React.Component<
const props: GalleryViewerComponentProps = { const props: GalleryViewerComponentProps = {
container: this.props.container, container: this.props.container,
isGalleryPublishEnabled: this.props.isGalleryPublishEnabled,
junoClient: this.props.junoClient, junoClient: this.props.junoClient,
selectedTab: this.state.selectedTab, selectedTab: this.state.selectedTab,
sortBy: this.state.sortBy, sortBy: this.state.sortBy,

View File

@ -7,14 +7,20 @@ import {
} from "./GalleryAndNotebookViewerComponent"; } from "./GalleryAndNotebookViewerComponent";
export class GalleryAndNotebookViewerComponentAdapter implements ReactAdapter { export class GalleryAndNotebookViewerComponentAdapter implements ReactAdapter {
private key: string;
public parameters: ko.Observable<number>; public parameters: ko.Observable<number>;
constructor(private props: GalleryAndNotebookViewerComponentProps) { constructor(private props: GalleryAndNotebookViewerComponentProps) {
this.reset();
this.parameters = ko.observable<number>(Date.now()); this.parameters = ko.observable<number>(Date.now());
} }
public renderComponent(): JSX.Element { public renderComponent(): JSX.Element {
return <GalleryAndNotebookViewerComponent {...this.props} />; return <GalleryAndNotebookViewerComponent key={this.key} {...this.props} />;
}
public reset(): void {
this.key = `GalleryAndNotebookViewerComponent-${Date.now()}`;
} }
public triggerRender(): void { public triggerRender(): void {

View File

@ -6,4 +6,16 @@
overflow-y: auto; overflow-y: auto;
width: 100%; width: 100%;
font-family: @DataExplorerFont; font-family: @DataExplorerFont;
background: @GalleryBackgroundColor;
}
.publicGalleryTabContainer {
position: relative;
height: 100vh;
}
.publicGalleryTabOverlayContent {
background: white;
padding: 20px;
margin: 10%;
} }

View File

@ -5,6 +5,7 @@ import { GalleryViewerComponent, GalleryViewerComponentProps, GalleryTab, SortBy
describe("GalleryViewerComponent", () => { describe("GalleryViewerComponent", () => {
it("renders", () => { it("renders", () => {
const props: GalleryViewerComponentProps = { const props: GalleryViewerComponentProps = {
isGalleryPublishEnabled: false,
junoClient: undefined, junoClient: undefined,
selectedTab: GalleryTab.OfficialSamples, selectedTab: GalleryTab.OfficialSamples,
sortBy: SortBy.MostViewed, sortBy: SortBy.MostViewed,

View File

@ -9,10 +9,14 @@ import {
IPivotProps, IPivotProps,
IRectangle, IRectangle,
Label, Label,
Link,
List, List,
Overlay,
Pivot, Pivot,
PivotItem, PivotItem,
SearchBox, SearchBox,
Spinner,
SpinnerSize,
Stack, Stack,
Text, Text,
} from "office-ui-fabric-react"; } from "office-ui-fabric-react";
@ -27,9 +31,12 @@ import Explorer from "../../Explorer";
import { CodeOfConductComponent } from "./CodeOfConductComponent"; import { CodeOfConductComponent } from "./CodeOfConductComponent";
import { InfoComponent } from "./InfoComponent/InfoComponent"; import { InfoComponent } from "./InfoComponent/InfoComponent";
import { handleError } from "../../../Common/ErrorHandlingUtils"; import { handleError } from "../../../Common/ErrorHandlingUtils";
import { trace } from "../../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
export interface GalleryViewerComponentProps { export interface GalleryViewerComponentProps {
container?: Explorer; container?: Explorer;
isGalleryPublishEnabled: boolean;
junoClient: JunoClient; junoClient: JunoClient;
selectedTab: GalleryTab; selectedTab: GalleryTab;
sortBy: SortBy; sortBy: SortBy;
@ -64,6 +71,8 @@ interface GalleryViewerComponentState {
searchText: string; searchText: string;
dialogProps: DialogProps; dialogProps: DialogProps;
isCodeOfConductAccepted: boolean; isCodeOfConductAccepted: boolean;
isFetchingPublishedNotebooks: boolean;
isFetchingFavouriteNotebooks: boolean;
} }
interface GalleryTabInfo { interface GalleryTabInfo {
@ -74,18 +83,24 @@ interface GalleryTabInfo {
export class GalleryViewerComponent extends React.Component<GalleryViewerComponentProps, GalleryViewerComponentState> { export class GalleryViewerComponent extends React.Component<GalleryViewerComponentProps, GalleryViewerComponentState> {
public static readonly OfficialSamplesTitle = "Official samples"; public static readonly OfficialSamplesTitle = "Official samples";
public static readonly PublicGalleryTitle = "Public gallery"; public static readonly PublicGalleryTitle = "Public gallery";
public static readonly FavoritesTitle = "Liked"; public static readonly FavoritesTitle = "My favorites";
public static readonly PublishedTitle = "Your published work"; public static readonly PublishedTitle = "My published work";
private static readonly rowsPerPage = 5; private static readonly rowsPerPage = 5;
private static readonly mostViewedText = "Most viewed"; private static readonly mostViewedText = "Most viewed";
private static readonly mostDownloadedText = "Most downloaded"; 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 static readonly mostRecentText = "Most recent";
private readonly sortingOptions: IDropdownOption[]; private readonly sortingOptions: IDropdownOption[];
private viewGalleryTraced: boolean;
private viewOfficialSamplesTraced: boolean;
private viewPublicGalleryTraced: boolean;
private viewFavoritesTraced: boolean;
private viewPublishedNotebooksTraced: boolean;
private sampleNotebooks: IGalleryItem[]; private sampleNotebooks: IGalleryItem[];
private publicNotebooks: IGalleryItem[]; private publicNotebooks: IGalleryItem[];
private favoriteNotebooks: IGalleryItem[]; private favoriteNotebooks: IGalleryItem[];
@ -107,6 +122,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
searchText: props.searchText, searchText: props.searchText,
dialogProps: undefined, dialogProps: undefined,
isCodeOfConductAccepted: undefined, isCodeOfConductAccepted: undefined,
isFetchingFavouriteNotebooks: true,
isFetchingPublishedNotebooks: true,
}; };
this.sortingOptions = [ this.sortingOptions = [
@ -137,9 +154,11 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
} }
public render(): JSX.Element { public render(): JSX.Element {
this.traceViewGallery();
const tabs: GalleryTabInfo[] = [this.createSamplesTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)]; const tabs: GalleryTabInfo[] = [this.createSamplesTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
if (this.props.container?.isGalleryPublishEnabled()) { if (this.props.isGalleryPublishEnabled) {
tabs.push( tabs.push(
this.createPublicGalleryTab( this.createPublicGalleryTab(
GalleryTab.PublicGallery, GalleryTab.PublicGallery,
@ -147,13 +166,11 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
this.state.isCodeOfConductAccepted this.state.isCodeOfConductAccepted
) )
); );
tabs.push(this.createFavoritesTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
// explicitly checking if isCodeOfConductAccepted is not false, as it is initially undefined.
// Displaying code of conduct component on gallery load should not be the default behavior.
if (this.state.isCodeOfConductAccepted !== false) {
tabs.push(this.createPublishedNotebooksTab(GalleryTab.Published, this.state.publishedNotebooks));
} }
if (this.props.container?.isGalleryPublishEnabled()) {
tabs.push(this.createFavoritesTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
tabs.push(this.createPublishedNotebooksTab(GalleryTab.Published, this.state.publishedNotebooks));
} }
const pivotProps: IPivotProps = { const pivotProps: IPivotProps = {
@ -184,11 +201,58 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
); );
} }
private traceViewGallery = (): void => {
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 => { private isEmptyData = (data: IGalleryItem[]): boolean => {
return !data || data.length === 0; 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 ( return (
<Stack horizontalAlign="center" tokens={{ childrenGap: 10 }}> <Stack horizontalAlign="center" tokens={{ childrenGap: 10 }}>
<FontIcon iconName={iconName} style={{ fontSize: 100, color: "lightgray", marginTop: 20 }} /> <FontIcon iconName={iconName} style={{ fontSize: 100, color: "lightgray", marginTop: 20 }} />
@ -216,40 +280,63 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
}; };
} }
private getFavouriteNotebooksTabContent = (data: IGalleryItem[]) => {
if (this.isEmptyData(data)) {
if (this.state.isFetchingFavouriteNotebooks) {
return <Spinner size={SpinnerSize.large} />;
}
return this.createEmptyTabContent(
"ContactHeart",
<>You don&apos;t have any favorites yet</>,
<>
Favorite any notebook from the{" "}
<Link onClick={() => this.setState({ selectedTab: GalleryTab.OfficialSamples })}>official samples</Link> or{" "}
<Link onClick={() => this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery</Link>
</>
);
}
return this.createSearchBarHeader(this.createCardsTabContent(data));
};
private createFavoritesTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo { private createFavoritesTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo {
return { return {
tab, tab,
content: this.isEmptyData(data) content: this.getFavouriteNotebooksTabContent(data),
? this.createEmptyTabContent(
"ContactHeart",
"You have not liked anything",
"Like any notebook from Official Samples or Public gallery"
)
: this.createSearchBarHeader(this.createCardsTabContent(data)),
}; };
} }
private getPublishedNotebooksTabContent = (data: IGalleryItem[]) => {
if (this.isEmptyData(data)) {
if (this.state.isFetchingPublishedNotebooks) {
return <Spinner size={SpinnerSize.large} />;
}
return this.createEmptyTabContent(
"Contact",
<>
You have not published anything to the{" "}
<Link onClick={() => this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery</Link> yet
</>,
<>Publish your notebooks to share your work with other users</>
);
}
return this.createPublishedNotebooksTabContent(data);
};
private createPublishedNotebooksTab = (tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo => { private createPublishedNotebooksTab = (tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo => {
return { return {
tab, tab,
content: this.isEmptyData(data) content: this.getPublishedNotebooksTabContent(data),
? this.createEmptyTabContent(
"Contact",
"You have not published anything",
"Publish your sample notebooks to share your published work with others"
)
: this.createPublishedNotebooksTabContent(data),
}; };
}; };
private createPublishedNotebooksTabContent = (data: IGalleryItem[]): JSX.Element => { private createPublishedNotebooksTabContent = (data: IGalleryItem[]): JSX.Element => {
const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(data); const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(data);
const content = ( const content = (
<Stack tokens={{ childrenGap: 10 }}> <Stack tokens={{ childrenGap: 20 }}>
{published?.length > 0 && {published?.length > 0 &&
this.createPublishedNotebooksSectionContent( this.createPublishedNotebooksSectionContent(
undefined, 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) this.createCardsTabContent(published)
)} )}
{underReview?.length > 0 && {underReview?.length > 0 &&
@ -276,24 +363,33 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
content: JSX.Element content: JSX.Element
): JSX.Element => { ): JSX.Element => {
return ( return (
<Stack tokens={{ childrenGap: 5 }}> <Stack tokens={{ childrenGap: 10 }}>
{title && <Text styles={{ root: { fontWeight: FontWeights.semibold } }}>{title}</Text>} {title && (
{description && <Text>{description}</Text>} <Text styles={{ root: { fontWeight: FontWeights.semibold, marginLeft: 10, marginRight: 10 } }}>{title}</Text>
)}
{description && <Text styles={{ root: { marginLeft: 10, marginRight: 10 } }}>{description}</Text>}
{content} {content}
</Stack> </Stack>
); );
}; };
private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element { private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element {
return acceptedCodeOfConduct === false ? ( return (
<div className="publicGalleryTabContainer">
{this.createSearchBarHeader(this.createCardsTabContent(data))}
{acceptedCodeOfConduct === false && (
<Overlay isDarkThemed>
<div className="publicGalleryTabOverlayContent">
<CodeOfConductComponent <CodeOfConductComponent
junoClient={this.props.junoClient} junoClient={this.props.junoClient}
onAcceptCodeOfConduct={(result: boolean) => { onAcceptCodeOfConduct={(result: boolean) => {
this.setState({ isCodeOfConductAccepted: result }); this.setState({ isCodeOfConductAccepted: result });
}} }}
/> />
) : ( </div>
this.createSearchBarHeader(this.createCardsTabContent(data)) </Overlay>
)}
</div>
); );
} }
@ -310,7 +406,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
<Stack.Item styles={{ root: { minWidth: 200 } }}> <Stack.Item styles={{ root: { minWidth: 200 } }}>
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} /> <Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
</Stack.Item> </Stack.Item>
{(!this.props.container || this.props.container.isGalleryPublishEnabled()) && ( {this.props.isGalleryPublishEnabled && (
<Stack.Item> <Stack.Item>
<InfoComponent /> <InfoComponent />
</Stack.Item> </Stack.Item>
@ -322,7 +418,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
} }
private createCardsTabContent(data: IGalleryItem[]): JSX.Element { private createCardsTabContent(data: IGalleryItem[]): JSX.Element {
return ( return data ? (
<FocusZone> <FocusZone>
<List <List
items={data} items={data}
@ -331,12 +427,14 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
onRenderCell={this.onRenderCell} onRenderCell={this.onRenderCell}
/> />
</FocusZone> </FocusZone>
) : (
<Spinner size={SpinnerSize.large} />
); );
} }
private createPolicyViolationsListContent(data: IGalleryItem[]): JSX.Element { private createPolicyViolationsListContent(data: IGalleryItem[]): JSX.Element {
return ( return (
<table> <table style={{ margin: 10 }}>
<tbody> <tbody>
<tr> <tr>
<th>Name</th> <th>Name</th>
@ -385,6 +483,10 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
} }
this.sampleNotebooks = response.data; this.sampleNotebooks = response.data;
trace(Action.NotebooksGalleryOfficialSamplesCount, ActionModifiers.Mark, {
count: this.sampleNotebooks?.length,
});
} catch (error) { } catch (error) {
handleError(error, "GalleryViewerComponent/loadSampleNotebooks", "Failed to load sample notebooks"); handleError(error, "GalleryViewerComponent/loadSampleNotebooks", "Failed to load sample notebooks");
} }
@ -411,6 +513,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading public notebooks`); throw new Error(`Received HTTP ${response.status} when loading public notebooks`);
} }
trace(Action.NotebooksGalleryPublicGalleryCount, ActionModifiers.Mark, { count: this.publicNotebooks?.length });
} catch (error) { } catch (error) {
handleError(error, "GalleryViewerComponent/loadPublicNotebooks", "Failed to load public notebooks"); handleError(error, "GalleryViewerComponent/loadPublicNotebooks", "Failed to load public notebooks");
} }
@ -425,14 +529,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private async loadFavoriteNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> { private async loadFavoriteNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) { if (!offline) {
try { try {
this.setState({ isFetchingFavouriteNotebooks: true });
const response = await this.props.junoClient.getFavoriteNotebooks(); const response = await this.props.junoClient.getFavoriteNotebooks();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading favorite notebooks`); throw new Error(`Received HTTP ${response.status} when loading favorite notebooks`);
} }
this.favoriteNotebooks = response.data; this.favoriteNotebooks = response.data;
trace(Action.NotebooksGalleryFavoritesCount, ActionModifiers.Mark, { count: this.favoriteNotebooks?.length });
} catch (error) { } catch (error) {
handleError(error, "GalleryViewerComponent/loadFavoriteNotebooks", "Failed to load favorite notebooks"); handleError(error, "GalleryViewerComponent/loadFavoriteNotebooks", "Failed to load favorite notebooks");
} finally {
this.setState({ isFetchingFavouriteNotebooks: false });
} }
} }
@ -451,14 +560,25 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private async loadPublishedNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> { private async loadPublishedNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) { if (!offline) {
try { try {
this.setState({ isFetchingPublishedNotebooks: true });
const response = await this.props.junoClient.getPublishedNotebooks(); const response = await this.props.junoClient.getPublishedNotebooks();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading published notebooks`); throw new Error(`Received HTTP ${response.status} when loading published notebooks`);
} }
this.publishedNotebooks = response.data; 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) { } catch (error) {
handleError(error, "GalleryViewerComponent/loadPublishedNotebooks", "Failed to load published notebooks"); handleError(error, "GalleryViewerComponent/loadPublishedNotebooks", "Failed to load published notebooks");
} finally {
this.setState({ isFetchingPublishedNotebooks: false });
} }
} }

View File

@ -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
</Text> </Text>
</StackItem> </StackItem>
<StackItem> <StackItem>
<Text> <Text>
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.
</Text> </Text>
</StackItem> </StackItem>
<StackItem> <StackItem>
<Text> <Text>
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
<StyledLinkBase <StyledLinkBase
href="https://aka.ms/cosmos-code-of-conduct" href="https://aka.ms/cosmos-code-of-conduct"
target="_blank" target="_blank"
> >
code of conduct code of conduct.
</StyledLinkBase>
and
<StyledLinkBase
href="https://aka.ms/ms-privacy-policy"
target="_blank"
>
privacy statement
</StyledLinkBase> </StyledLinkBase>
</Text> </Text>
</StackItem> </StackItem>
<StackItem> <StackItem>
<StyledCheckboxBase <StyledCheckboxBase
label="I have read and accepted the code of conduct and privacy statement" label="I have read and accept the code of conduct."
onChange={[Function]} onChange={[Function]}
styles={ styles={
Object { Object {

View File

@ -77,24 +77,11 @@ exports[`GalleryViewerComponent renders 1`] = `
selectedKey={0} selectedKey={0}
/> />
</StackItem> </StackItem>
<StackItem>
<InfoComponent />
</StackItem>
</Stack> </Stack>
<StackItem> <StackItem>
<FocusZone <StyledSpinnerBase
direction={2} size={3}
isCircularNavigation={false}
shouldRaiseClicks={true}
>
<List
getPageSpecification={[Function]}
onRenderCell={[Function]}
renderedWindowsAhead={3}
renderedWindowsBehind={2}
startIndex={0}
/> />
</FocusZone>
</StackItem> </StackItem>
</Stack> </Stack>
</PivotItem> </PivotItem>

View File

@ -31,6 +31,26 @@ export interface NotebookMetadataComponentProps {
} }
export class NotebookMetadataComponent extends React.Component<NotebookMetadataComponentProps> { export class NotebookMetadataComponent extends React.Component<NotebookMetadataComponentProps> {
private renderFavouriteButton = (): JSX.Element => {
return (
<Text>
{this.props.isFavorite !== undefined ? (
<>
<IconButton
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
/>
{this.props.data.favorites} likes
</>
) : (
<>
<Icon iconName="Heart" /> {this.props.data.favorites} likes
</>
)}
</Text>
);
};
public render(): JSX.Element { public render(): JSX.Element {
const options: Intl.DateTimeFormatOptions = { const options: Intl.DateTimeFormatOptions = {
year: "numeric", year: "numeric",
@ -49,19 +69,7 @@ export class NotebookMetadataComponent extends React.Component<NotebookMetadataC
</Text> </Text>
</Stack.Item> </Stack.Item>
<Stack.Item> <Stack.Item>{this.renderFavouriteButton()}</Stack.Item>
<Text>
{this.props.isFavorite !== undefined && (
<>
<IconButton
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
/>
{this.props.data.favorites} likes
</>
)}
</Text>
</Stack.Item>
{this.props.downloadButtonText && ( {this.props.downloadButtonText && (
<Stack.Item> <Stack.Item>

View File

@ -3,14 +3,11 @@
*/ */
import { Notebook } from "@nteract/commutable"; import { Notebook } from "@nteract/commutable";
import { createContentRef } from "@nteract/core"; 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 * as React from "react";
import { contents } from "rx-jupyter"; import { contents } from "rx-jupyter";
import * as Logger from "../../../Common/Logger";
import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient";
import * as GalleryUtils from "../../../Utils/GalleryUtils"; import * as GalleryUtils from "../../../Utils/GalleryUtils";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
import { NotebookClientV2 } from "../../Notebook/NotebookClientV2"; import { NotebookClientV2 } from "../../Notebook/NotebookClientV2";
import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper"; import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper";
import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer"; import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer";
@ -21,7 +18,9 @@ import Explorer from "../../Explorer";
import { NotebookV4 } from "@nteract/commutable/lib/v4"; import { NotebookV4 } from "@nteract/commutable/lib/v4";
import { SessionStorageUtility } from "../../../Shared/StorageUtility"; import { SessionStorageUtility } from "../../../Shared/StorageUtility";
import { DialogHost } from "../../../Utils/GalleryUtils"; 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 { export interface NotebookViewerComponentProps {
container?: Explorer; container?: Explorer;
@ -80,6 +79,12 @@ export class NotebookViewerComponent
} }
private async loadNotebookContent(): Promise<void> { private async loadNotebookContent(): Promise<void> {
const startKey = traceStart(Action.NotebooksGalleryViewNotebook, {
notebookUrl: this.props.notebookUrl,
notebookId: this.props.galleryItem?.id,
isSample: this.props.galleryItem?.isSample,
});
try { try {
const response = await fetch(this.props.notebookUrl); const response = await fetch(this.props.notebookUrl);
if (!response.ok) { if (!response.ok) {
@ -87,6 +92,16 @@ export class NotebookViewerComponent
throw new Error(`Received HTTP ${response.status} while fetching ${this.props.notebookUrl}`); 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(); const notebook: Notebook = await response.json();
this.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId); this.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId);
this.notebookComponentBootstrapper.setContent("json", notebook); this.notebookComponentBootstrapper.setContent("json", notebook);
@ -101,6 +116,18 @@ export class NotebookViewerComponent
SessionStorageUtility.setEntry(this.props.galleryItem?.id, "true"); SessionStorageUtility.setEntry(this.props.galleryItem?.id, "true");
} }
} catch (error) { } 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 }); this.setState({ showProgressBar: false });
handleError(error, "NotebookViewerComponent/loadNotebookContent", "Failed to load notebook content"); 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 // DialogHost
showOkCancelModalDialog( showOkCancelModalDialog(
title: string, title: string,
@ -186,8 +239,10 @@ export class NotebookViewerComponent
onOk: () => void, onOk: () => void,
cancelLabel: string, cancelLabel: string,
onCancel: () => void, onCancel: () => void,
progressIndicatorProps?: IProgressIndicatorProps,
choiceGroupProps?: IChoiceGroupProps, choiceGroupProps?: IChoiceGroupProps,
textFieldProps?: TextFieldProps textFieldProps?: TextFieldProps,
primaryButtonDisabled?: boolean
): void { ): void {
this.setState({ this.setState({
dialogProps: { dialogProps: {
@ -205,8 +260,10 @@ export class NotebookViewerComponent
this.setState({ dialogProps: undefined }); this.setState({ dialogProps: undefined });
onCancel && onCancel(); onCancel && onCancel();
}, },
progressIndicatorProps,
choiceGroupProps, choiceGroupProps,
textFieldProps, textFieldProps,
primaryButtonDisabled,
}, },
}); });
} }

View File

@ -2,7 +2,7 @@ import { shallow } from "enzyme";
import React from "react"; import React from "react";
import { SettingsComponentProps, SettingsComponent, SettingsComponentState } from "./SettingsComponent"; import { SettingsComponentProps, SettingsComponent, SettingsComponentState } from "./SettingsComponent";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import SettingsTabV2 from "../../Tabs/SettingsTabV2"; import { CollectionSettingsTabV2 } from "../../Tabs/SettingsTabV2";
import { collection } from "./TestUtils"; import { collection } from "./TestUtils";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import ko from "knockout"; import ko from "knockout";
@ -37,16 +37,15 @@ jest.mock("../../../Common/dataAccess/updateOffer", () => ({
describe("SettingsComponent", () => { describe("SettingsComponent", () => {
const baseProps: SettingsComponentProps = { const baseProps: SettingsComponentProps = {
settingsTab: new SettingsTabV2({ settingsTab: new CollectionSettingsTabV2({
collection: collection, collection: collection,
tabKind: ViewModels.CollectionTabKind.SettingsV2, tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
title: "Scale & Settings", title: "Scale & Settings",
tabPath: "", tabPath: "",
node: undefined, node: undefined,
hashLocation: "settings", hashLocation: "settings",
isActive: ko.observable(false), isActive: ko.observable(false),
onUpdateTabsButtons: undefined, onUpdateTabsButtons: undefined,
getPendingNotification: Promise.resolve(undefined),
}), }),
}; };
@ -139,6 +138,7 @@ describe("SettingsComponent", () => {
readSettings: undefined, readSettings: undefined,
onSettingsClick: undefined, onSettingsClick: undefined,
loadOffer: undefined, loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database; } as ViewModels.Database;
newCollection.getDatabase = () => newDatabase; newCollection.getDatabase = () => newDatabase;
newCollection.offer = ko.observable(undefined); newCollection.offer = ko.observable(undefined);

View File

@ -11,7 +11,7 @@ import Explorer from "../../Explorer";
import { updateOffer } from "../../../Common/dataAccess/updateOffer"; import { updateOffer } from "../../../Common/dataAccess/updateOffer";
import { updateCollection, updateMongoDBCollectionThroughRP } from "../../../Common/dataAccess/updateCollection"; import { updateCollection, updateMongoDBCollectionThroughRP } from "../../../Common/dataAccess/updateCollection";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import SettingsTab from "../../Tabs/SettingsTabV2"; import { SettingsTabV2 } from "../../Tabs/SettingsTabV2";
import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils"; import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils";
import { ScaleComponent, ScaleComponentProps } from "./SettingsSubComponents/ScaleComponent"; import { ScaleComponent, ScaleComponentProps } from "./SettingsSubComponents/ScaleComponent";
import { import {
@ -58,7 +58,7 @@ interface ButtonV2 {
} }
export interface SettingsComponentProps { export interface SettingsComponentProps {
settingsTab: SettingsTab; settingsTab: SettingsTabV2;
} }
export interface SettingsComponentState { export interface SettingsComponentState {
@ -116,7 +116,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private discardSettingsChangesButton: ButtonV2; private discardSettingsChangesButton: ButtonV2;
private isAnalyticalStorageEnabled: boolean; private isAnalyticalStorageEnabled: boolean;
private isCollectionSettingsTab: boolean;
private collection: ViewModels.Collection; private collection: ViewModels.Collection;
private database: ViewModels.Database;
private offer: DataModels.Offer;
private container: Explorer; private container: Explorer;
private changeFeedPolicyVisible: boolean; private changeFeedPolicyVisible: boolean;
private isFixedContainer: boolean; private isFixedContainer: boolean;
@ -126,8 +129,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
constructor(props: SettingsComponentProps) { constructor(props: SettingsComponentProps) {
super(props); super(props);
this.isCollectionSettingsTab = this.props.settingsTab.tabKind === ViewModels.CollectionTabKind.CollectionSettingsV2;
if (this.isCollectionSettingsTab) {
this.collection = this.props.settingsTab.collection as ViewModels.Collection; this.collection = this.props.settingsTab.collection as ViewModels.Collection;
this.container = this.collection?.container; this.container = this.collection?.container;
this.offer = this.collection?.offer();
this.isAnalyticalStorageEnabled = !!this.collection?.analyticalStorageTtl(); this.isAnalyticalStorageEnabled = !!this.collection?.analyticalStorageTtl();
this.shouldShowIndexingPolicyEditor = this.shouldShowIndexingPolicyEditor =
this.container && !this.container.isPreferredApiCassandra() && !this.container.isPreferredApiMongoDB(); this.container && !this.container.isPreferredApiCassandra() && !this.container.isPreferredApiMongoDB();
@ -139,7 +145,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
// Mongo container with system partition key still treat as "Fixed" // Mongo container with system partition key still treat as "Fixed"
this.isFixedContainer = this.isFixedContainer =
this.container.isPreferredApiMongoDB() && this.container.isPreferredApiMongoDB() &&
(!this.collection.partitionKey || this.collection.partitionKey.systemKey); (!this.collection?.partitionKey || this.collection?.partitionKey.systemKey);
} else {
this.database = this.props.settingsTab.database;
this.container = this.database?.container;
this.offer = this.database?.offer();
}
this.state = { this.state = {
throughput: undefined, throughput: undefined,
@ -206,18 +217,21 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
} }
componentDidMount(): void { componentDidMount(): void {
if (this.isCollectionSettingsTab) {
this.refreshIndexTransformationProgress(); this.refreshIndexTransformationProgress();
this.loadMongoIndexes(); this.loadMongoIndexes();
}
this.setAutoPilotStates(); this.setAutoPilotStates();
this.setBaseline(); this.setBaseline();
if (this.props.settingsTab.isActive()) { if (this.props.settingsTab.isActive()) {
this.props.settingsTab.getSettingsTabContainer().onUpdateTabsButtons(this.getTabsButtons()); this.props.settingsTab.getContainer().onUpdateTabsButtons(this.getTabsButtons());
} }
} }
componentDidUpdate(): void { componentDidUpdate(): void {
if (this.props.settingsTab.isActive()) { if (this.props.settingsTab.isActive()) {
this.props.settingsTab.getSettingsTabContainer().onUpdateTabsButtons(this.getTabsButtons()); this.props.settingsTab.getContainer().onUpdateTabsButtons(this.getTabsButtons());
} }
} }
@ -270,7 +284,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}; };
private setAutoPilotStates = (): void => { private setAutoPilotStates = (): void => {
const autoscaleMaxThroughput = this.collection?.offer()?.autoscaleMaxThroughput; const autoscaleMaxThroughput = this.offer?.autoscaleMaxThroughput;
if (autoscaleMaxThroughput && AutoPilotUtils.isValidAutoPilotThroughput(autoscaleMaxThroughput)) { if (autoscaleMaxThroughput && AutoPilotUtils.isValidAutoPilotThroughput(autoscaleMaxThroughput)) {
this.setState({ this.setState({
@ -295,7 +309,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
!!this.collection.conflictResolutionPolicy(); !!this.collection.conflictResolutionPolicy();
public isOfferReplacePending = (): boolean => { public isOfferReplacePending = (): boolean => {
return this.collection?.offer()?.offerReplacePending; return this.offer?.offerReplacePending;
}; };
public onSaveClick = async (): Promise<void> => { public onSaveClick = async (): Promise<void> => {
@ -309,174 +323,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
tabTitle: this.props.settingsTab.tabTitle(), tabTitle: this.props.settingsTab.tabTitle(),
}); });
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
try { try {
if ( await (this.isCollectionSettingsTab
this.state.isSubSettingsSaveable || ? this.saveCollectionSettings(startKey)
this.state.isIndexingPolicyDirty || : this.saveDatabaseSettings(startKey));
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.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
);
} catch (error) { } catch (error) {
this.container.isRefreshingExplorer(false); this.container.isRefreshingExplorer(false);
this.props.settingsTab.isExecutionError(true); this.props.settingsTab.isExecutionError(true);
@ -495,8 +345,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}, },
startKey startKey
); );
} } finally {
this.props.settingsTab.isExecuting(false); this.props.settingsTab.isExecuting(false);
}
}; };
public onRevertClick = (): void => { public onRevertClick = (): void => {
@ -693,6 +544,17 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}; };
public setBaseline = (): void => { public setBaseline = (): void => {
const offerThroughput = this.offer?.manualThroughput;
if (!this.isCollectionSettingsTab) {
this.setState({
throughput: offerThroughput,
throughputBaseline: offerThroughput,
});
return;
}
const defaultTtl = this.collection.defaultTtl(); const defaultTtl = this.collection.defaultTtl();
let timeToLive: TtlType = this.state.timeToLive; let timeToLive: TtlType = this.state.timeToLive;
@ -725,7 +587,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
} }
} }
const offerThroughput = this.collection.offer()?.manualThroughput;
const changeFeedPolicy = this.collection.rawDataModel?.changeFeedPolicy const changeFeedPolicy = this.collection.rawDataModel?.changeFeedPolicy
? ChangeFeedPolicyState.On ? ChangeFeedPolicyState.On
: ChangeFeedPolicyState.Off; : ChangeFeedPolicyState.Off;
@ -811,9 +672,225 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({ selectedTab: selectedTab }); this.setState({ selectedTab: selectedTab });
}; };
private saveDatabaseSettings = async (startKey: number): Promise<void> => {
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<void> => {
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 { public render(): JSX.Element {
const scaleComponentProps: ScaleComponentProps = { const scaleComponentProps: ScaleComponentProps = {
collection: this.collection, collection: this.collection,
database: this.database,
container: this.container, container: this.container,
isFixedContainer: this.isFixedContainer, isFixedContainer: this.isFixedContainer,
onThroughputChange: this.onThroughputChange, onThroughputChange: this.onThroughputChange,
@ -830,6 +907,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
initialNotification: this.props.settingsTab.pendingNotification(), initialNotification: this.props.settingsTab.pendingNotification(),
}; };
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">
<div className="settingsV2TabsContainer">
<ScaleComponent {...scaleComponentProps} />
</div>
</div>
);
}
const subSettingsComponentProps: SubSettingsComponentProps = { const subSettingsComponentProps: SubSettingsComponentProps = {
collection: this.collection, collection: this.collection,
container: this.container, container: this.container,
@ -899,7 +986,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}; };
const tabs: SettingsV2TabInfo[] = []; const tabs: SettingsV2TabInfo[] = [];
if (!hasDatabaseSharedThroughput(this.collection) && this.collection.offer()) { if (!hasDatabaseSharedThroughput(this.collection) && this.offer) {
tabs.push({ tabs.push({
tab: SettingsV2TabTypes.ScaleTab, tab: SettingsV2TabTypes.ScaleTab,
content: <ScaleComponent {...scaleComponentProps} />, content: <ScaleComponent {...scaleComponentProps} />,

View File

@ -375,7 +375,7 @@ export const getThroughputApplyShortDelayMessage = (
<Text styles={infoAndToolTipTextStyle} id="throughputApplyShortDelayMessage"> <Text styles={infoAndToolTipTextStyle} id="throughputApplyShortDelayMessage">
A request to increase the throughput is currently in progress. This operation will take some time to complete. A request to increase the throughput is currently in progress. This operation will take some time to complete.
<br /> <br />
Database: {databaseName}, Container: {collectionName}{" "} {collectionName ? `Database: ${databaseName}, Container: ${collectionName} ` : `Database: ${databaseName} `}
{getCurrentThroughput(isAutoscale, throughput, throughputUnit)} {getCurrentThroughput(isAutoscale, throughput, throughputUnit)}
</Text> </Text>
); );
@ -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 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. complete. View the latest status in Notifications.
<br /> <br />
Database: {databaseName}, Container: {collectionName}{" "} {collectionName ? `Database: ${databaseName}, Container: ${collectionName} ` : `Database: ${databaseName} `}
{getCurrentThroughput(isAutoscale, throughput, throughputUnit, requestedThroughput)} {getCurrentThroughput(isAutoscale, throughput, throughputUnit, requestedThroughput)}
</Text> </Text>
); );

View File

@ -18,6 +18,7 @@ describe("ScaleComponent", () => {
const baseProps: ScaleComponentProps = { const baseProps: ScaleComponentProps = {
collection: collection, collection: collection,
database: undefined,
container: container, container: container,
isFixedContainer: false, isFixedContainer: false,
onThroughputChange: () => { onThroughputChange: () => {

View File

@ -21,6 +21,7 @@ import { configContext, Platform } from "../../../../ConfigContext";
export interface ScaleComponentProps { export interface ScaleComponentProps {
collection: ViewModels.Collection; collection: ViewModels.Collection;
database: ViewModels.Database;
container: Explorer; container: Explorer;
isFixedContainer: boolean; isFixedContainer: boolean;
onThroughputChange: (newThroughput: number) => void; onThroughputChange: (newThroughput: number) => void;
@ -39,9 +40,16 @@ export interface ScaleComponentProps {
export class ScaleComponent extends React.Component<ScaleComponentProps> { export class ScaleComponent extends React.Component<ScaleComponentProps> {
private isEmulator: boolean; private isEmulator: boolean;
private offer: DataModels.Offer;
private databaseId: string;
private collectionId: string;
constructor(props: ScaleComponentProps) { constructor(props: ScaleComponentProps) {
super(props); super(props);
this.isEmulator = configContext.platform === Platform.Emulator; 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 => { public isAutoScaleEnabled = (): boolean => {
@ -87,9 +95,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
return SharedConstants.CollectionCreation.DefaultCollectionRUs400; return SharedConstants.CollectionCreation.DefaultCollectionRUs400;
} }
return ( return this.offer?.minimumThroughput || SharedConstants.CollectionCreation.DefaultCollectionRUs400;
this.props.collection.offer()?.minimumThroughput || SharedConstants.CollectionCreation.DefaultCollectionRUs400
);
}; };
public getThroughputTitle = (): string => { public getThroughputTitle = (): string => {
@ -115,15 +121,14 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
return this.getLongDelayMessage(); return this.getLongDelayMessage();
} }
const offer = this.props.collection?.offer(); if (this.offer?.offerReplacePending) {
if (offer?.offerReplacePending) { const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput;
const throughput = offer.manualThroughput || offer.autoscaleMaxThroughput;
return getThroughputApplyShortDelayMessage( return getThroughputApplyShortDelayMessage(
this.props.isAutoPilotSelected, this.props.isAutoPilotSelected,
throughput, throughput,
throughputUnit, throughputUnit,
this.props.collection.databaseId, this.databaseId,
this.props.collection.id() this.collectionId
); );
} }
@ -135,7 +140,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
this.canThroughputExceedMaximumValue() && this.canThroughputExceedMaximumValue() &&
this.props.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million; this.props.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million;
if (throughputExceedsBackendLimits && !!this.props.collection.partitionKey && !this.props.isFixedContainer) { if (throughputExceedsBackendLimits && !this.props.isFixedContainer) {
return updateThroughputBeyondLimitWarningMessage; return updateThroughputBeyondLimitWarningMessage;
} }
@ -154,8 +159,8 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
this.props.wasAutopilotOriginallySet, this.props.wasAutopilotOriginallySet,
throughput, throughput,
throughputUnit, throughputUnit,
this.props.collection.databaseId, this.databaseId,
this.props.collection.id(), this.collectionId,
targetThroughput targetThroughput
); );
} }
@ -165,15 +170,15 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
private getThroughputInputComponent = (): JSX.Element => ( private getThroughputInputComponent = (): JSX.Element => (
<ThroughputInputAutoPilotV3Component <ThroughputInputAutoPilotV3Component
databaseAccount={this.props.container.databaseAccount()} databaseAccount={this.props.container.databaseAccount()}
databaseName={this.props.collection.databaseId} databaseName={this.databaseId}
collectionName={this.props.collection.id()} collectionName={this.collectionId}
serverId={this.props.container.serverId()} serverId={this.props.container.serverId()}
throughput={this.props.throughput} throughput={this.props.throughput}
throughputBaseline={this.props.throughputBaseline} throughputBaseline={this.props.throughputBaseline}
onThroughputChange={this.props.onThroughputChange} onThroughputChange={this.props.onThroughputChange}
minimum={this.getMinRUs()} minimum={this.getMinRUs()}
maximum={this.getMaxRUs()} maximum={this.getMaxRUs()}
isEnabled={!hasDatabaseSharedThroughput(this.props.collection)} isEnabled={!!this.props.database || !hasDatabaseSharedThroughput(this.props.collection)}
canExceedMaximumValue={this.canThroughputExceedMaximumValue()} canExceedMaximumValue={this.canThroughputExceedMaximumValue()}
label={this.getThroughputTitle()} label={this.getThroughputTitle()}
isEmulator={this.isEmulator} isEmulator={this.isEmulator}
@ -189,7 +194,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
onScaleSaveableChange={this.props.onScaleSaveableChange} onScaleSaveableChange={this.props.onScaleSaveableChange}
onScaleDiscardableChange={this.props.onScaleDiscardableChange} onScaleDiscardableChange={this.props.onScaleDiscardableChange}
getThroughputWarningMessage={this.getThroughputWarningMessage} getThroughputWarningMessage={this.getThroughputWarningMessage}
usageSizeInKB={this.props.collection.usageSizeInKB()} usageSizeInKB={this.props.collection?.usageSizeInKB()}
/> />
); );
@ -230,7 +235,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
{!this.isAutoScaleEnabled() && ( {!this.isAutoScaleEnabled() && (
<Stack {...subComponentStackProps}> <Stack {...subComponentStackProps}>
{this.getThroughputInputComponent()} {this.getThroughputInputComponent()}
{this.getStorageCapacityTitle()} {!this.props.database && this.getStorageCapacityTitle()}
</Stack> </Stack>
)} )}

View File

@ -40,6 +40,7 @@ import { userContext } from "../../../../../UserContext";
import { SubscriptionType } from "../../../../../Contracts/SubscriptionType"; import { SubscriptionType } from "../../../../../Contracts/SubscriptionType";
import { usageInGB, calculateEstimateNumber } from "../../../../../Utils/PricingUtils"; import { usageInGB, calculateEstimateNumber } from "../../../../../Utils/PricingUtils";
import { Features } from "../../../../../Common/Constants"; import { Features } from "../../../../../Common/Constants";
import { minAutoPilotThroughput } from "../../../../../Utils/AutoPilotUtils";
import * as TelemetryProcessor from "../../../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../../../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../../../../Shared/Telemetry/TelemetryConstants";
@ -541,6 +542,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
step={AutoPilotUtils.autoPilotIncrementStep} step={AutoPilotUtils.autoPilotIncrementStep}
value={this.overrideWithProvisionedThroughputSettings() ? "" : this.props.maxAutoPilotThroughput?.toString()} value={this.overrideWithProvisionedThroughputSettings() ? "" : this.props.maxAutoPilotThroughput?.toString()}
onChange={this.onAutoPilotThroughputChange} onChange={this.onAutoPilotThroughputChange}
min={minAutoPilotThroughput}
/> />
{!this.overrideWithProvisionedThroughputSettings() && this.getAutoPilotUsageCost()} {!this.overrideWithProvisionedThroughputSettings() && this.getAutoPilotUsageCost()}
{this.minRUperGBSurvey()} {this.minRUperGBSurvey()}
@ -579,6 +581,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
: this.props.throughput?.toString() : this.props.throughput?.toString()
} }
onChange={this.onThroughputChange} onChange={this.onThroughputChange}
min={this.props.minimum}
/> />
{this.state.exceedFreeTierThroughput && ( {this.state.exceedFreeTierThroughput && (
<MessageBar <MessageBar

View File

@ -142,6 +142,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
id="autopilotInput" id="autopilotInput"
key="auto pilot throughput input" key="auto pilot throughput input"
label="Max RU/s" label="Max RU/s"
min={4000}
onChange={[Function]} onChange={[Function]}
required={true} required={true}
step={1000} step={1000}
@ -260,6 +261,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
disabled={false} disabled={false}
id="throughputInput" id="throughputInput"
key="provisioned throughput input" key="provisioned throughput input"
min={10000}
onChange={[Function]} onChange={[Function]}
required={true} required={true}
step={100} step={100}
@ -533,6 +535,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
disabled={false} disabled={false}
id="throughputInput" id="throughputInput"
key="provisioned throughput input" key="provisioned throughput input"
min={10000}
onChange={[Function]} onChange={[Function]}
required={true} required={true}
step={100} step={100}

View File

@ -23,11 +23,7 @@ exports[`ScaleComponent renders with correct initial notification 1`] = `
> >
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. 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.
<br /> <br />
Database: Database: test, Container: test
test
, Container:
test
, Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s , Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s
</Text> </Text>
</StyledMessageBarBase> </StyledMessageBarBase>

View File

@ -46,6 +46,7 @@ describe("SettingsUtils", () => {
readSettings: undefined, readSettings: undefined,
onSettingsClick: undefined, onSettingsClick: undefined,
loadOffer: undefined, loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database; } as ViewModels.Database;
}; };
newCollection.offer(undefined); newCollection.offer(undefined);

View File

@ -804,6 +804,7 @@ exports[`SettingsComponent renders 1`] = `
}, },
"clickHostedAccountSwitch": [Function], "clickHostedAccountSwitch": [Function],
"clickHostedDirectorySwitch": [Function], "clickHostedDirectorySwitch": [Function],
"closeSidePanel": undefined,
"collapsedResourceTreeWidth": 36, "collapsedResourceTreeWidth": 36,
"collectionCreationDefaults": Object { "collectionCreationDefaults": Object {
"storage": "100", "storage": "100",
@ -1021,6 +1022,7 @@ exports[`SettingsComponent renders 1`] = `
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function], "onSwitchToConnectionString": [Function],
"onToggleKeyDown": [Function], "onToggleKeyDown": [Function],
"openSidePanel": undefined,
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
@ -2083,6 +2085,7 @@ exports[`SettingsComponent renders 1`] = `
}, },
"clickHostedAccountSwitch": [Function], "clickHostedAccountSwitch": [Function],
"clickHostedDirectorySwitch": [Function], "clickHostedDirectorySwitch": [Function],
"closeSidePanel": undefined,
"collapsedResourceTreeWidth": 36, "collapsedResourceTreeWidth": 36,
"collectionCreationDefaults": Object { "collectionCreationDefaults": Object {
"storage": "100", "storage": "100",
@ -2300,6 +2303,7 @@ exports[`SettingsComponent renders 1`] = `
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function], "onSwitchToConnectionString": [Function],
"onToggleKeyDown": [Function], "onToggleKeyDown": [Function],
"openSidePanel": undefined,
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
@ -3375,6 +3379,7 @@ exports[`SettingsComponent renders 1`] = `
}, },
"clickHostedAccountSwitch": [Function], "clickHostedAccountSwitch": [Function],
"clickHostedDirectorySwitch": [Function], "clickHostedDirectorySwitch": [Function],
"closeSidePanel": undefined,
"collapsedResourceTreeWidth": 36, "collapsedResourceTreeWidth": 36,
"collectionCreationDefaults": Object { "collectionCreationDefaults": Object {
"storage": "100", "storage": "100",
@ -3592,6 +3597,7 @@ exports[`SettingsComponent renders 1`] = `
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function], "onSwitchToConnectionString": [Function],
"onToggleKeyDown": [Function], "onToggleKeyDown": [Function],
"openSidePanel": undefined,
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
@ -4654,6 +4660,7 @@ exports[`SettingsComponent renders 1`] = `
}, },
"clickHostedAccountSwitch": [Function], "clickHostedAccountSwitch": [Function],
"clickHostedDirectorySwitch": [Function], "clickHostedDirectorySwitch": [Function],
"closeSidePanel": undefined,
"collapsedResourceTreeWidth": 36, "collapsedResourceTreeWidth": 36,
"collectionCreationDefaults": Object { "collectionCreationDefaults": Object {
"storage": "100", "storage": "100",
@ -4871,6 +4878,7 @@ exports[`SettingsComponent renders 1`] = `
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function], "onSwitchToConnectionString": [Function],
"onToggleKeyDown": [Function], "onToggleKeyDown": [Function],
"openSidePanel": undefined,
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],

View File

@ -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. A request to increase the throughput is currently in progress. This operation will take some time to complete.
<br /> <br />
Database: Database: sampleDb, Container: sampleCollection
sampleDb
, Container:
sampleCollection
, Current manual throughput: 1000 RU/s , Current manual throughput: 1000 RU/s
</Text> </Text>
<Text <Text
@ -275,11 +271,7 @@ exports[`SettingsUtils functions render 1`] = `
> >
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. 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.
<br /> <br />
Database: Database: sampleDb, Container: sampleCollection
sampleDb
, Container:
sampleCollection
, Current manual throughput: 1000 RU/s, Target manual throughput: 2000 , Current manual throughput: 1000 RU/s, Target manual throughput: 2000
</Text> </Text>
<Text <Text

View File

@ -1,3 +1,4 @@
import React from "react";
import * as ComponentRegisterer from "./ComponentRegisterer"; import * as ComponentRegisterer from "./ComponentRegisterer";
import * as Constants from "../Common/Constants"; import * as Constants from "../Common/Constants";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
@ -47,7 +48,6 @@ import { ExplorerMetrics } from "../Common/Constants";
import { ExplorerSettings } from "../Shared/ExplorerSettings"; import { ExplorerSettings } from "../Shared/ExplorerSettings";
import { FileSystemUtil } from "./Notebook/FileSystemUtil"; import { FileSystemUtil } from "./Notebook/FileSystemUtil";
import { handleOpenAction } from "./OpenActions"; import { handleOpenAction } from "./OpenActions";
import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation";
import { IGalleryItem } from "../Juno/JunoClient"; import { IGalleryItem } from "../Juno/JunoClient";
import { LoadQueryPane } from "./Panes/LoadQueryPane"; import { LoadQueryPane } from "./Panes/LoadQueryPane";
import * as Logger from "../Common/Logger"; import * as Logger from "../Common/Logger";
@ -91,6 +91,8 @@ import { appInsights } from "../Shared/appInsights";
import { SelfServeLoadingComponentAdapter } from "../SelfServe/SelfServeLoadingComponentAdapter"; import { SelfServeLoadingComponentAdapter } from "../SelfServe/SelfServeLoadingComponentAdapter";
import { SelfServeType } from "../SelfServe/SelfServeUtils"; import { SelfServeType } from "../SelfServe/SelfServeUtils";
import { SelfServeComponentAdapter } from "../SelfServe/SelfServeComponentAdapter"; import { SelfServeComponentAdapter } from "../SelfServe/SelfServeComponentAdapter";
import { GalleryTab } from "./Controls/NotebookGallery/GalleryViewerComponent";
import { DeleteCollectionConfirmationPanel } from "./Panes/DeleteCollectionConfirmationPanel";
BindingHandlersRegisterer.registerBindingHandlers(); BindingHandlersRegisterer.registerBindingHandlers();
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import // Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
@ -110,6 +112,8 @@ export interface ExplorerParams {
setIsNotificationConsoleExpanded: (isExpanded: boolean) => void; setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
setNotificationConsoleData: (consoleData: ConsoleData) => void; setNotificationConsoleData: (consoleData: ConsoleData) => void;
setInProgressConsoleDataIdToBeDeleted: (id: string) => void; setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
openSidePanel: (headerText: string, panelContent: JSX.Element) => void;
closeSidePanel: () => void;
} }
export default class Explorer { export default class Explorer {
@ -157,6 +161,8 @@ export default class Explorer {
// Panes // Panes
public contextPanes: ContextualPaneBase[]; public contextPanes: ContextualPaneBase[];
private openSidePanel: (headerText: string, panelContent: JSX.Element) => void;
private closeSidePanel: () => void;
// Resource Tree // Resource Tree
public databases: ko.ObservableArray<ViewModels.Database>; public databases: ko.ObservableArray<ViewModels.Database>;
@ -278,6 +284,8 @@ export default class Explorer {
this.setIsNotificationConsoleExpanded = params?.setIsNotificationConsoleExpanded; this.setIsNotificationConsoleExpanded = params?.setIsNotificationConsoleExpanded;
this.setNotificationConsoleData = params?.setNotificationConsoleData; this.setNotificationConsoleData = params?.setNotificationConsoleData;
this.setInProgressConsoleDataIdToBeDeleted = params?.setInProgressConsoleDataIdToBeDeleted; this.setInProgressConsoleDataIdToBeDeleted = params?.setInProgressConsoleDataIdToBeDeleted;
this.openSidePanel = params?.openSidePanel;
this.closeSidePanel = params?.closeSidePanel;
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree, dataExplorerArea: Constants.Areas.ResourceTree,
@ -423,8 +431,8 @@ export default class Explorer {
this.shouldShowShareDialogContents = ko.observable<boolean>(false); this.shouldShowShareDialogContents = ko.observable<boolean>(false);
this.shouldShowDataAccessExpiryDialog = ko.observable<boolean>(false); this.shouldShowDataAccessExpiryDialog = ko.observable<boolean>(false);
this.shouldShowContextSwitchPrompt = ko.observable<boolean>(false); this.shouldShowContextSwitchPrompt = ko.observable<boolean>(false);
this.isGalleryPublishEnabled = ko.computed<boolean>(() => this.isGalleryPublishEnabled = ko.computed<boolean>(
this.isFeatureEnabled(Constants.Features.enableGalleryPublish) () => configContext.ENABLE_GALLERY_PUBLISH || this.isFeatureEnabled(Constants.Features.enableGalleryPublish)
); );
this.isLinkInjectionEnabled = ko.computed<boolean>(() => this.isLinkInjectionEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableLinkInjection) this.isFeatureEnabled(Constants.Features.enableLinkInjection)
@ -1889,6 +1897,9 @@ export default class Explorer {
if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) { if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) {
this.isMongoIndexingEnabled(true); this.isMongoIndexingEnabled(true);
} }
if (flights.indexOf(Constants.Flights.GalleryPublish) !== -1) {
this.isGalleryPublishEnabled = ko.computed<boolean>(() => true);
}
} }
public findSelectedCollection(): ViewModels.Collection { public findSelectedCollection(): ViewModels.Collection {
@ -2249,7 +2260,7 @@ export default class Explorer {
return Promise.resolve(false); return Promise.resolve(false);
} }
public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise<void> { public async publishNotebook(name: string, content: string | unknown, parentDomElement?: HTMLElement): Promise<void> {
if (this.notebookManager) { if (this.notebookManager) {
await this.notebookManager.openPublishNotebookPane( await this.notebookManager.openPublishNotebookPane(
name, name,
@ -2810,28 +2821,21 @@ 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 title: string = "Gallery";
let hashLocation: string = "gallery"; let hashLocation: string = "gallery";
const galleryTabs = this.tabsManager.getTabs( const galleryTabOptions: any = {
ViewModels.CollectionTabKind.Gallery,
(tab) => tab.hashLocation() == hashLocation
);
let galleryTab = galleryTabs && galleryTabs[0];
if (galleryTab) {
this.tabsManager.activateTab(galleryTab);
} else {
if (!this.galleryTab) {
this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab");
}
const newTab = new this.galleryTab.default({
// GalleryTabOptions // GalleryTabOptions
account: userContext.databaseAccount, account: userContext.databaseAccount,
container: this, container: this,
junoClient: this.notebookManager?.junoClient, junoClient: this.notebookManager?.junoClient,
selectedTab: selectedTab || GalleryTab.OfficialSamples,
notebookUrl, notebookUrl,
galleryItem, galleryItem,
isFavorite, isFavorite,
@ -2845,8 +2849,22 @@ export default class Explorer {
onUpdateTabsButtons: this.onUpdateTabsButtons, onUpdateTabsButtons: this.onUpdateTabsButtons,
isTabsContentExpanded: ko.observable(true), isTabsContentExpanded: ko.observable(true),
onLoadStartKey: null, onLoadStartKey: null,
}); };
const galleryTabs = this.tabsManager.getTabs(
ViewModels.CollectionTabKind.Gallery,
(tab) => tab.hashLocation() == hashLocation
);
let galleryTab = galleryTabs && galleryTabs[0];
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);
this.tabsManager.activateNewTab(newTab); this.tabsManager.activateNewTab(newTab);
} }
} }
@ -3028,4 +3046,17 @@ export default class Explorer {
return false; return false;
}); });
} }
public openDeleteCollectionConfirmationPane(): void {
this.isFeatureEnabled(Constants.Features.enableKOPanel)
? this.deleteCollectionConfirmationPane.open()
: this.openSidePanel(
"Delete Collection",
<DeleteCollectionConfirmationPanel
explorer={this}
closePanel={() => this.closeSidePanel()}
openNotificationConsole={() => this.expandConsole()}
/>
);
}
} }

View File

@ -114,6 +114,7 @@ export class NotificationConsoleComponent extends React.Component<
<div className="notificationConsoleContainer"> <div className="notificationConsoleContainer">
<div <div
className="notificationConsoleHeader" className="notificationConsoleHeader"
id="notificationConsoleHeader"
ref={this.setElememntRef} ref={this.setElememntRef}
onClick={(event: React.MouseEvent<HTMLDivElement>) => this.expandCollapseConsole()} onClick={(event: React.MouseEvent<HTMLDivElement>) => this.expandCollapseConsole()}
onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>) => this.onExpandCollapseKeyPress(event)} onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>) => this.onExpandCollapseKeyPress(event)}

View File

@ -6,6 +6,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
> >
<div <div
className="notificationConsoleHeader" className="notificationConsoleHeader"
id="notificationConsoleHeader"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0} tabIndex={0}
@ -169,6 +170,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
> >
<div <div
className="notificationConsoleHeader" className="notificationConsoleHeader"
id="notificationConsoleHeader"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0} tabIndex={0}

View File

@ -818,7 +818,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
let indexingPolicy: DataModels.IndexingPolicy; let indexingPolicy: DataModels.IndexingPolicy;
let createMongoWildcardIndex: boolean; let createMongoWildcardIndex: boolean;
// todo - remove mongo indexing policy ticket # 616274 // todo - remove mongo indexing policy ticket # 616274
if (this.container.isPreferredApiMongoDB()) { if (this.container.isPreferredApiMongoDB() && this.container.isEnableMongoCapabilityPresent()) {
createMongoWildcardIndex = this.shouldCreateMongoWildcardIndex(); createMongoWildcardIndex = this.shouldCreateMongoWildcardIndex();
} else if (this.showIndexingOptionsForSharedThroughput()) { } else if (this.showIndexingOptionsForSharedThroughput()) {
if (this.useIndexingForSharedThroughput()) { if (this.useIndexingForSharedThroughput()) {

View File

@ -1,142 +0,0 @@
jest.mock("../../Common/dataAccess/deleteCollection");
import * as ko from "knockout";
import * as sinon from "sinon";
import Q from "q";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import DeleteCollectionConfirmationPane from "./DeleteCollectionConfirmationPane";
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";
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", () => {
let database = {} as ViewModels.Database;
database.collections = ko.observableArray<ViewModels.Collection>([{} as ViewModels.Collection]);
explorer.databases = ko.observableArray<ViewModels.Database>([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<ViewModels.Collection>([
{} as ViewModels.Collection,
{} as ViewModels.Collection,
]);
explorer.databases = ko.observableArray<ViewModels.Database>([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<ViewModels.Collection>([{} as ViewModels.Collection]);
let database2 = {} as ViewModels.Database;
database2.collections = ko.observableArray<ViewModels.Collection>([{} as ViewModels.Collection]);
explorer.databases = ko.observableArray<ViewModels.Database>([database, database2]);
expect(explorer.isLastCollection()).toBe(false);
});
it("should be false if 0 databases", () => {
let database = {} as ViewModels.Database;
explorer.databases = ko.observableArray<ViewModels.Database>();
database.collections = ko.observableArray<ViewModels.Collection>();
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<boolean>(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<string>(selectedCollectionId),
rid: "test",
} as ViewModels.Collection;
};
fakeExplorer.selectedCollectionId = ko.computed<string>(() => selectedCollectionId);
fakeExplorer.isSelectedDatabaseShared = () => false;
const SubscriptionId = "testId";
const AccountName = "testAccount";
fakeExplorer.databaseAccount = ko.observable<DataModels.DatabaseAccount>({
id: SubscriptionId,
name: AccountName,
} as DataModels.DatabaseAccount);
fakeExplorer.defaultExperience = ko.observable<string>("DocumentDB");
fakeExplorer.isPreferredApiCassandra = ko.computed(() => {
return false;
});
fakeExplorer.selectedNode = ko.observable<TreeNode>();
fakeExplorer.isLastCollection = () => true;
fakeExplorer.isSelectedDatabaseShared = () => false;
fakeExplorer.refreshAllDatabases = () => Q.resolve();
let pane = new DeleteCollectionConfirmationPane({
id: "deletecollectionconfirmationpane",
visible: ko.observable<boolean>(false),
container: fakeExplorer as any,
});
pane.collectionIdConfirmation = ko.observable<string>(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);
});
});
});
});

View File

@ -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<Collection>([{} as Collection]);
explorer.databases = ko.observableArray<Database>([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<Collection>([{} as Collection, {} as Collection]);
explorer.databases = ko.observableArray<Database>([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<Collection>([{} as Collection]);
const database2 = {} as Database;
database2.collections = ko.observableArray<Collection>([{} as Collection]);
explorer.databases = ko.observableArray<Database>([database, database2]);
expect(explorer.isLastCollection()).toBe(false);
});
it("should be false if 0 databases", () => {
const database = {} as Database;
explorer.databases = ko.observableArray<Database>();
database.collections = ko.observableArray<Collection>();
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(<DeleteCollectionConfirmationPanel {...props} />);
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<string>(selectedCollectionId),
databaseId,
rid: "test",
} as Collection;
};
fakeExplorer.selectedCollectionId = ko.computed<string>(() => selectedCollectionId);
fakeExplorer.selectedNode = ko.observable<TreeNode>();
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(<DeleteCollectionConfirmationPanel {...props} />);
});
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();
});
});
});

View File

@ -1,9 +1,7 @@
import * as ko from "knockout"; import * as ko from "knockout";
import Q from "q";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { CassandraAPIDataClient } from "../Tables/TableDataClient";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { ContextualPaneBase } from "./ContextualPaneBase"; import { ContextualPaneBase } from "./ContextualPaneBase";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility"; import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
@ -50,18 +48,7 @@ export default class DeleteCollectionConfirmationPane extends ContextualPaneBase
dataExplorerArea: Constants.Areas.ContextualPane, dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(), paneTitle: this.title(),
}); });
let promise: Promise<any>; return deleteCollection(selectedCollection.databaseId, selectedCollection.id()).then(
if (this.container.isPreferredApiCassandra()) {
promise = ((<CassandraAPIDataClient>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<any>;
} else {
promise = deleteCollection(selectedCollection.databaseId, selectedCollection.id());
}
return promise.then(
() => { () => {
this.isExecuting(false); this.isExecuting(false);
this.close(); this.close();

View File

@ -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 (
<div className="panelContentContainer">
<PanelErrorComponent {...this.getPanelErrorProps()} />
<div className="panelMainContent">
<div className="confirmDeleteInput">
<span className="mandatoryStar">* </span>
<Text variant="small">Confirm by typing the collection id</Text>
<TextField
id="confirmCollectionId"
autoFocus
styles={{ fieldGroup: { width: 300 } }}
onChange={(event, newInput?: string) => {
this.inputCollectionName = newInput;
}}
/>
</div>
{this.shouldRecordFeedback() && (
<div className="deleteCollectionFeedback">
<Text variant="small" block>
Help us improve Azure Cosmos DB!
</Text>
<Text variant="small" block>
What is the reason why you are deleting this container?
</Text>
<TextField
id="deleteCollectionFeedbackInput"
styles={{ fieldGroup: { width: 300 } }}
multiline
rows={3}
onChange={(event, newInput?: string) => {
this.deleteCollectionFeedback = newInput;
}}
/>
</div>
)}
</div>
<PanelFooterComponent buttonLabel="OK" onOKButtonClicked={() => this.submit()} />
<div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" hidden={!this.state.isExecuting}>
<img className="dataExplorerLoader" src={LoadingIndicator_3Squares} />
</div>
</div>
);
}
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<void> {
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
);
}
}
}

View File

@ -52,19 +52,8 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase {
dataExplorerArea: Constants.Areas.ContextualPane, dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(), paneTitle: this.title(),
}); });
// TODO: Should not be a Q promise anymore, but the Cassandra code requires it return Q(
let promise: Q.Promise<any>; deleteDatabase(selectedDatabase.id()).then(
if (this.container.isPreferredApiCassandra()) {
promise = (<CassandraAPIDataClient>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.isExecuting(false);
this.close(); this.close();
@ -127,6 +116,7 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase {
startKey startKey
); );
} }
)
); );
} }

View File

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

View File

@ -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: <div></div>,
isOpen: true,
isConsoleExpanded: false,
closePanel: undefined,
};
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
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(<PanelContainerComponent {...panelContainerProps} />);
expect(wrapper).toMatchSnapshot();
});
it("should be resize if notification console is expanded", () => {
const panelContainerProps: PanelContainerProps = {
headerText: "test",
panelContent: <div></div>,
isOpen: true,
isConsoleExpanded: true,
closePanel: undefined,
};
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -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<PanelContainerProps> {
private static readonly consoleHeaderHeight = 32;
private static readonly consoleContentHeight = 220;
render(): JSX.Element {
if (!this.props.panelContent) {
return <></>;
}
return (
<Panel
headerText={this.props.headerText}
isOpen={this.props.isOpen}
onDismiss={this.onDissmiss}
isLightDismiss
type={PanelType.custom}
closeButtonAriaLabel="Close"
customWidth="440px"
headerClassName="panelHeader"
styles={{
navigation: { borderBottom: "1px solid #cccccc" },
content: { padding: "24px 34px 20px 34px", height: "100%" },
scrollableContent: { height: "100%" },
}}
style={{ height: this.getPanelHeight() }}
>
{this.props.panelContent}
</Panel>
);
}
private onDissmiss = (ev?: React.SyntheticEvent<HTMLElement>): 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";
};
}

View File

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

View File

@ -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<PanelFooterProps> = (
props: PanelFooterProps
): JSX.Element => (
<div className="panelFooter">
<PrimaryButton id="sidePanelOkButton" text={props.buttonLabel} onClick={() => props.onOKButtonClicked()} />
</div>
);

View File

@ -10,7 +10,11 @@ import { ImmutableNotebook } from "@nteract/commutable/src";
import { toJS } from "@nteract/commutable"; import { toJS } from "@nteract/commutable";
import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent"; import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
import { HttpStatusCodes } from "../../Common/Constants"; 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 { export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>; parameters: ko.Observable<number>;
@ -66,7 +70,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
onChangeDescription: (newValue: string) => (this.description = newValue), onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue), onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue), onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection, onError: this.createFormError,
clearFormError: this.clearFormError, clearFormError: this.clearFormError,
}; };
@ -140,11 +144,22 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.isExecuting = true; this.isExecuting = true;
this.triggerRender(); this.triggerRender();
try { let startKey: number;
if (!this.name || !this.description || !this.author) {
throw new Error("Name, description, and author are required"); 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 {
startKey = traceStart(Action.NotebooksGalleryPublish, {
databaseAccountName: this.container.databaseAccount()?.name,
defaultExperience: this.container.defaultExperience(),
});
const response = await this.junoClient.publishNotebook( const response = await this.junoClient.publishNotebook(
this.name, this.name,
this.description, this.description,
@ -157,17 +172,43 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
const data = response.data; const data = response.data;
if (data) { if (data) {
let isPublishPending = false;
if (data.pendingScanJobIds?.length > 0) { if (data.pendingScanJobIds?.length > 0) {
isPublishPending = true;
NotificationConsoleUtils.logConsoleInfo( 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).` `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 { } else {
NotificationConsoleUtils.logConsoleInfo(`Published ${this.name} to gallery`); 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) { } 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); 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}`; this.formErrorDetail = `${errorMessage}`;
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", this.formError); handleError(errorMessage, "PublishNotebookPaneAdapter/submit", this.formError);
return; return;
@ -180,7 +221,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.close(); this.close();
} }
private createFormErrorForLargeImageSelection = (formError: string, formErrorDetail: string, area: string): void => { private createFormError = (formError: string, formErrorDetail: string, area: string): void => {
this.formError = formError; this.formError = formError;
this.formErrorDetail = formErrorDetail; this.formErrorDetail = formErrorDetail;
handleError(formErrorDetail, area, formError); handleError(formErrorDetail, area, formError);

View File

@ -14,7 +14,7 @@ export interface PublishNotebookPaneProps {
notebookAuthor: string; notebookAuthor: string;
notebookCreatedDate: string; notebookCreatedDate: string;
notebookObject: ImmutableNotebook; notebookObject: ImmutableNotebook;
notebookParentDomElement: HTMLElement; notebookParentDomElement?: HTMLElement;
onChangeName: (newValue: string) => void; onChangeName: (newValue: string) => void;
onChangeDescription: (newValue: string) => void; onChangeDescription: (newValue: string) => void;
onChangeTags: (newValue: string) => void; onChangeTags: (newValue: string) => void;
@ -54,7 +54,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
super(props); super(props);
this.state = { this.state = {
type: ImageTypes.Url, type: ImageTypes.CustomImage,
notebookName: props.notebookName, notebookName: props.notebookName,
notebookDescription: "", notebookDescription: "",
notebookTags: "", notebookTags: "",
@ -110,7 +110,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
}; };
this.descriptionPara1 = this.descriptionPara1 =
"This notebook has your data. Please make sure you delete any sensitive data/output before publishing."; "When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.";
this.descriptionPara2 = `Would you like to publish and share "${FileSystemUtil.stripExtension( this.descriptionPara2 = `Would you like to publish and share "${FileSystemUtil.stripExtension(
this.props.notebookName, this.props.notebookName,
@ -120,6 +120,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
this.thumbnailUrlProps = { this.thumbnailUrlProps = {
label: "Cover image url", label: "Cover image url",
ariaLabel: "Cover image url", ariaLabel: "Cover image url",
required: true,
onChange: (event, newValue) => { onChange: (event, newValue) => {
this.props.onChangeImageSrc(newValue); this.props.onChangeImageSrc(newValue);
this.setState({ imageSrc: newValue }); this.setState({ imageSrc: newValue });
@ -140,17 +141,23 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
this.props.onError(formError, formErrorDetail, area); this.props.onError(formError, formErrorDetail, area);
}; };
const options: ImageTypes[] = [ImageTypes.CustomImage, ImageTypes.Url];
if (this.props.notebookParentDomElement) {
options.push(ImageTypes.TakeScreenshot);
if (this.props.notebookObject) {
options.push(ImageTypes.UseFirstDisplayOutput);
}
}
this.thumbnailSelectorProps = { this.thumbnailSelectorProps = {
label: "Cover image", label: "Cover image",
defaultSelectedKey: ImageTypes.Url, defaultSelectedKey: ImageTypes.CustomImage,
ariaLabel: "Cover image", ariaLabel: "Cover image",
options: [ options: options.map((value: string) => ({ text: value, key: value })),
ImageTypes.Url,
ImageTypes.CustomImage,
ImageTypes.TakeScreenshot,
ImageTypes.UseFirstDisplayOutput,
].map((value: string) => ({ text: value, key: value })),
onChange: async (event, options) => { onChange: async (event, options) => {
this.setState({ imageSrc: undefined });
this.props.onChangeImageSrc(undefined);
this.props.clearFormError(); this.props.clearFormError();
if (options.text === ImageTypes.TakeScreenshot) { if (options.text === ImageTypes.TakeScreenshot) {
try { try {
@ -172,11 +179,12 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
this.nameProps = { this.nameProps = {
label: "Name", label: "Name",
ariaLabel: "Name", ariaLabel: "Name",
defaultValue: this.props.notebookName, defaultValue: FileSystemUtil.stripExtension(this.props.notebookName, "ipynb"),
required: true, required: true,
onChange: (event, newValue) => { onChange: (event, newValue) => {
this.props.onChangeName(newValue); const notebookName = newValue + ".ipynb";
this.setState({ notebookName: newValue }); this.props.onChangeName(notebookName);
this.setState({ notebookName });
}, },
}; };
@ -293,16 +301,16 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
thumbnailUrl: this.state.imageSrc, thumbnailUrl: this.state.imageSrc,
created: this.props.notebookCreatedDate, created: this.props.notebookCreatedDate,
isSample: false, isSample: false,
downloads: 0, downloads: undefined,
favorites: 0, favorites: undefined,
views: 0, views: undefined,
newCellId: undefined, newCellId: undefined,
policyViolations: undefined, policyViolations: undefined,
pendingScanJobIds: undefined, pendingScanJobIds: undefined,
}} }}
isFavorite={false} isFavorite={undefined}
showDownload={true} showDownload={false}
showDelete={true} showDelete={false}
onClick={undefined} onClick={undefined}
onTagClick={undefined} onTagClick={undefined}
onFavoriteClick={undefined} onFavoriteClick={undefined}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PaneContainerComponent test should be resize if notification console is expanded 1`] = `
<StyledPanelBase
closeButtonAriaLabel="Close"
customWidth="440px"
headerClassName="panelHeader"
headerText="test"
isLightDismiss={true}
isOpen={true}
onDismiss={[Function]}
style={
Object {
"height": "516px",
}
}
styles={
Object {
"content": Object {
"height": "100%",
"padding": "24px 34px 20px 34px",
},
"navigation": Object {
"borderBottom": "1px solid #cccccc",
},
"scrollableContent": Object {
"height": "100%",
},
}
}
type={7}
>
<div />
</StyledPanelBase>
`;
exports[`PaneContainerComponent test should render nothing if content is undefined 1`] = `<Fragment />`;
exports[`PaneContainerComponent test should render with panel content and header 1`] = `
<StyledPanelBase
closeButtonAriaLabel="Close"
customWidth="440px"
headerClassName="panelHeader"
headerText="test"
isLightDismiss={true}
isOpen={true}
onDismiss={[Function]}
style={
Object {
"height": "736px",
}
}
styles={
Object {
"content": Object {
"height": "100%",
"padding": "24px 34px 20px 34px",
},
"navigation": Object {
"borderBottom": "1px solid #cccccc",
},
"scrollableContent": Object {
"height": "100%",
},
}
}
type={7}
>
<div />
</StyledPanelBase>
`;

View File

@ -14,7 +14,7 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
> >
<StackItem> <StackItem>
<Text> <Text>
This notebook has your data. Please make sure you delete any sensitive data/output before publishing. When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.
</Text> </Text>
</StackItem> </StackItem>
<StackItem> <StackItem>
@ -25,7 +25,7 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
<StackItem> <StackItem>
<StyledTextFieldBase <StyledTextFieldBase
ariaLabel="Name" ariaLabel="Name"
defaultValue="SampleNotebook.ipynb" defaultValue="SampleNotebook"
label="Name" label="Name"
onChange={[Function]} onChange={[Function]}
required={true} required={true}
@ -52,36 +52,29 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
<StackItem> <StackItem>
<StyledWithResponsiveMode <StyledWithResponsiveMode
ariaLabel="Cover image" ariaLabel="Cover image"
defaultSelectedKey="URL" defaultSelectedKey="Custom Image"
label="Cover image" label="Cover image"
onChange={[Function]} onChange={[Function]}
options={ options={
Array [ Array [
Object {
"key": "URL",
"text": "URL",
},
Object { Object {
"key": "Custom Image", "key": "Custom Image",
"text": "Custom Image", "text": "Custom Image",
}, },
Object { Object {
"key": "Take Screenshot", "key": "URL",
"text": "Take Screenshot", "text": "URL",
},
Object {
"key": "Use First Display Output",
"text": "Use First Display Output",
}, },
] ]
} }
/> />
</StackItem> </StackItem>
<StackItem> <StackItem>
<StyledTextFieldBase <input
ariaLabel="Cover image url" accept="image/*"
label="Cover image url" id="selectImageFile"
onChange={[Function]} onChange={[Function]}
type="file"
/> />
</StackItem> </StackItem>
<StackItem> <StackItem>
@ -96,8 +89,8 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
"author": "CosmosDB", "author": "CosmosDB",
"created": "2020-07-17T00:00:00Z", "created": "2020-07-17T00:00:00Z",
"description": "", "description": "",
"downloads": 0, "downloads": undefined,
"favorites": 0, "favorites": undefined,
"gitSha": undefined, "gitSha": undefined,
"id": undefined, "id": undefined,
"isSample": false, "isSample": false,
@ -109,12 +102,11 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
"", "",
], ],
"thumbnailUrl": undefined, "thumbnailUrl": undefined,
"views": 0, "views": undefined,
} }
} }
isFavorite={false} showDelete={false}
showDelete={true} showDownload={false}
showDownload={true}
/> />
</StackItem> </StackItem>
</Stack> </Stack>

View File

@ -19,6 +19,7 @@ import { createDocument } from "../../Common/dataAccess/createDocument";
import { deleteDocument } from "../../Common/dataAccess/deleteDocument"; import { deleteDocument } from "../../Common/dataAccess/deleteDocument";
import { queryDocuments } from "../../Common/dataAccess/queryDocuments"; import { queryDocuments } from "../../Common/dataAccess/queryDocuments";
import { updateDocument } from "../../Common/dataAccess/updateDocument"; import { updateDocument } from "../../Common/dataAccess/updateDocument";
import { userContext } from "../../UserContext";
export interface CassandraTableKeys { export interface CassandraTableKeys {
partitionKeys: CassandraTableKey[]; partitionKeys: CassandraTableKey[];
@ -345,7 +346,7 @@ export class CassandraAPIDataClient extends TableDataClient {
ConsoleDataType.InProgress, ConsoleDataType.InProgress,
`Creating a new keyspace with query ${createKeyspaceQuery}` `Creating a new keyspace with query ${createKeyspaceQuery}`
); );
this.createOrDeleteQuery(cassandraEndpoint, resourceId, createKeyspaceQuery, explorer) this.createOrDeleteQuery(cassandraEndpoint, resourceId, createKeyspaceQuery)
.then( .then(
(data: any) => { (data: any) => {
NotificationConsoleUtils.logConsoleMessage( NotificationConsoleUtils.logConsoleMessage(
@ -391,7 +392,7 @@ export class CassandraAPIDataClient extends TableDataClient {
ConsoleDataType.InProgress, ConsoleDataType.InProgress,
`Creating a new table with query ${createTableQuery}` `Creating a new table with query ${createTableQuery}`
); );
this.createOrDeleteQuery(cassandraEndpoint, resourceId, createTableQuery, explorer) this.createOrDeleteQuery(cassandraEndpoint, resourceId, createTableQuery)
.then( .then(
(data: any) => { (data: any) => {
NotificationConsoleUtils.logConsoleMessage( NotificationConsoleUtils.logConsoleMessage(
@ -416,41 +417,6 @@ export class CassandraAPIDataClient extends TableDataClient {
return deferred.promise; return deferred.promise;
} }
public deleteTableOrKeyspace(
cassandraEndpoint: string,
resourceId: string,
deleteQuery: string,
explorer: Explorer
): Q.Promise<any> {
const deferred = Q.defer<any>();
const notificationId = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Deleting resource with query ${deleteQuery}`
);
this.createOrDeleteQuery(cassandraEndpoint, resourceId, deleteQuery, explorer)
.then(
() => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Info,
`Successfully deleted resource with query ${deleteQuery}`
);
deferred.resolve();
},
(error) => {
handleError(
error,
"DeleteKeyspaceOrTableCassandra",
`Error while deleting resource with query ${deleteQuery}`
);
deferred.reject(error);
}
)
.finally(() => {
NotificationConsoleUtils.clearInProgressMessageWithId(notificationId);
});
return deferred.promise;
}
public getTableKeys(collection: ViewModels.Collection): Q.Promise<CassandraTableKeys> { public getTableKeys(collection: ViewModels.Collection): Q.Promise<CassandraTableKeys> {
if (!!collection.cassandraKeys) { if (!!collection.cassandraKeys) {
return Q.resolve(collection.cassandraKeys); return Q.resolve(collection.cassandraKeys);
@ -551,12 +517,7 @@ export class CassandraAPIDataClient extends TableDataClient {
return deferred.promise; return deferred.promise;
} }
private createOrDeleteQuery( private createOrDeleteQuery(cassandraEndpoint: string, resourceId: string, query: string): Q.Promise<any> {
cassandraEndpoint: string,
resourceId: string,
query: string,
explorer: Explorer
): Q.Promise<any> {
const deferred = Q.defer(); const deferred = Q.defer();
const authType = window.authType; const authType = window.authType;
const apiEndpoint: string = const apiEndpoint: string =
@ -566,7 +527,7 @@ export class CassandraAPIDataClient extends TableDataClient {
$.ajax(`${configContext.BACKEND_ENDPOINT}/${apiEndpoint}`, { $.ajax(`${configContext.BACKEND_ENDPOINT}/${apiEndpoint}`, {
type: "POST", type: "POST",
data: { data: {
accountName: explorer.databaseAccount() && explorer.databaseAccount().name, accountName: userContext.databaseAccount?.name,
cassandraEndpoint: this.trimCassandraEndpoint(cassandraEndpoint), cassandraEndpoint: this.trimCassandraEndpoint(cassandraEndpoint),
resourceId: resourceId, resourceId: resourceId,
query: query, query: query,

View File

@ -387,8 +387,6 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
tabTitle: this.tabTitle(), tabTitle: this.tabTitle(),
}); });
const headerOptions: RequestOptions = { initialHeaders: {} };
try { try {
const updateOfferParams: DataModels.UpdateOfferParams = { const updateOfferParams: DataModels.UpdateOfferParams = {
databaseId: this.database.id(), databaseId: this.database.id(),

View File

@ -11,6 +11,7 @@ interface GalleryTabOptions extends ViewModels.TabOptions {
account: DatabaseAccount; account: DatabaseAccount;
container: Explorer; container: Explorer;
junoClient: JunoClient; junoClient: JunoClient;
selectedTab: GalleryViewerTab;
notebookUrl?: string; notebookUrl?: string;
galleryItem?: IGalleryItem; galleryItem?: IGalleryItem;
isFavorite?: boolean; isFavorite?: boolean;
@ -21,27 +22,46 @@ interface GalleryTabOptions extends ViewModels.TabOptions {
*/ */
export default class GalleryTab extends TabsBase { export default class GalleryTab extends TabsBase {
private container: Explorer; private container: Explorer;
private galleryAndNotebookViewerComponentProps: GalleryAndNotebookViewerComponentProps;
public galleryAndNotebookViewerComponentAdapter: GalleryAndNotebookViewerComponentAdapter; public galleryAndNotebookViewerComponentAdapter: GalleryAndNotebookViewerComponentAdapter;
constructor(options: GalleryTabOptions) { constructor(options: GalleryTabOptions) {
super(options); super(options);
this.container = options.container; this.container = options.container;
const props: GalleryAndNotebookViewerComponentProps = {
this.galleryAndNotebookViewerComponentProps = {
container: options.container, container: options.container,
isGalleryPublishEnabled: options.container.isGalleryPublishEnabled(),
junoClient: options.junoClient, junoClient: options.junoClient,
notebookUrl: options.notebookUrl, notebookUrl: options.notebookUrl,
galleryItem: options.galleryItem, galleryItem: options.galleryItem,
isFavorite: options.isFavorite, isFavorite: options.isFavorite,
selectedTab: GalleryViewerTab.OfficialSamples, selectedTab: options.selectedTab,
sortBy: SortBy.MostViewed, sortBy: SortBy.MostViewed,
searchText: undefined, searchText: undefined,
}; };
this.galleryAndNotebookViewerComponentAdapter = new GalleryAndNotebookViewerComponentAdapter(
this.galleryAndNotebookViewerComponentAdapter = new GalleryAndNotebookViewerComponentAdapter(props); this.galleryAndNotebookViewerComponentProps
);
} }
protected getContainer(): Explorer { public reset(options: GalleryTabOptions) {
this.container = options.container;
this.galleryAndNotebookViewerComponentProps.container = options.container;
this.galleryAndNotebookViewerComponentProps.junoClient = options.junoClient;
this.galleryAndNotebookViewerComponentProps.notebookUrl = options.notebookUrl;
this.galleryAndNotebookViewerComponentProps.galleryItem = options.galleryItem;
this.galleryAndNotebookViewerComponentProps.isFavorite = options.isFavorite;
this.galleryAndNotebookViewerComponentProps.selectedTab = options.selectedTab;
this.galleryAndNotebookViewerComponentProps.sortBy = SortBy.MostViewed;
this.galleryAndNotebookViewerComponentProps.searchText = undefined;
this.galleryAndNotebookViewerComponentAdapter.reset();
this.galleryAndNotebookViewerComponentAdapter.triggerRender();
}
public getContainer(): Explorer {
return this.container; return this.container;
} }
} }

View File

@ -18,7 +18,7 @@ import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-out
import InterruptKernelIcon from "../../../images/notebook/Notebook-stop.svg"; import InterruptKernelIcon from "../../../images/notebook/Notebook-stop.svg";
import KillKernelIcon from "../../../images/notebook/Notebook-stop.svg"; import KillKernelIcon from "../../../images/notebook/Notebook-stop.svg";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas, ArmApiVersions } from "../../Common/Constants"; import { Areas, ArmApiVersions } from "../../Common/Constants";
import { CommandBarComponentButtonFactory } from "../Menus/CommandBar/CommandBarComponentButtonFactory"; import { CommandBarComponentButtonFactory } from "../Menus/CommandBar/CommandBarComponentButtonFactory";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
@ -117,7 +117,7 @@ export default class NotebookTabV2 extends TabsBase {
return await this.configureServiceEndpoints(this.notebookComponentAdapter.getCurrentKernelName()); return await this.configureServiceEndpoints(this.notebookComponentAdapter.getCurrentKernelName());
} }
protected getContainer(): Explorer { public getContainer(): Explorer {
return this.container; return this.container;
} }
@ -485,6 +485,10 @@ export default class NotebookTabV2 extends TabsBase {
} }
private publishToGallery = async () => { private publishToGallery = async () => {
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
source: Source.CommandBarMenu,
});
const notebookContent = this.notebookComponentAdapter.getContent(); const notebookContent = this.notebookComponentAdapter.getContent();
await this.container.publishNotebook( await this.container.publishNotebook(
notebookContent.name, notebookContent.name,

View File

@ -58,7 +58,7 @@ export default class NotebookViewerTab extends TabsBase {
}); });
} }
protected getContainer(): Explorer { public getContainer(): Explorer {
return this.container; return this.container;
} }

View File

@ -3,7 +3,6 @@ import * as DataModels from "../../Contracts/DataModels";
import TabsBase from "./TabsBase"; import TabsBase from "./TabsBase";
import { SettingsComponentAdapter } from "../Controls/Settings/SettingsComponentAdapter"; import { SettingsComponentAdapter } from "../Controls/Settings/SettingsComponentAdapter";
import { SettingsComponentProps } from "../Controls/Settings/SettingsComponent"; import { SettingsComponentProps } from "../Controls/Settings/SettingsComponent";
import Explorer from "../Explorer";
import { traceFailure } from "../../Shared/Telemetry/TelemetryProcessor"; import { traceFailure } from "../../Shared/Telemetry/TelemetryProcessor";
import ko from "knockout"; import ko from "knockout";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
@ -11,23 +10,27 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { logConsoleError } from "../../Utils/NotificationConsoleUtils"; import { logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
export default class SettingsTabV2 extends TabsBase { export class SettingsTabV2 extends TabsBase {
public settingsComponentAdapter: SettingsComponentAdapter; public settingsComponentAdapter: SettingsComponentAdapter;
private notificationRead: ko.Observable<boolean>;
private notification: DataModels.Notification;
private offerRead: ko.Observable<boolean>;
private currentCollection: ViewModels.Collection;
private options: ViewModels.SettingsTabV2Options;
constructor(options: ViewModels.SettingsTabV2Options) { constructor(options: ViewModels.TabOptions) {
super(options); super(options);
this.options = options;
this.tabId = "SettingsV2-" + this.tabId;
const props: SettingsComponentProps = { const props: SettingsComponentProps = {
settingsTab: this, settingsTab: this,
}; };
this.settingsComponentAdapter = new SettingsComponentAdapter(props); this.settingsComponentAdapter = new SettingsComponentAdapter(props);
this.currentCollection = this.collection as ViewModels.Collection; }
}
export class CollectionSettingsTabV2 extends SettingsTabV2 {
private notificationRead: ko.Observable<boolean>;
private notification: DataModels.Notification;
private offerRead: ko.Observable<boolean>;
constructor(options: ViewModels.TabOptions) {
super(options);
this.tabId = "SettingsV2-" + this.tabId;
this.notificationRead = ko.observable(false); this.notificationRead = ko.observable(false);
this.offerRead = ko.observable(false); this.offerRead = ko.observable(false);
this.settingsComponentAdapter.parameters = ko.computed<boolean>(() => { this.settingsComponentAdapter.parameters = ko.computed<boolean>(() => {
@ -45,49 +48,95 @@ export default class SettingsTabV2 extends TabsBase {
public async onActivate(): Promise<void> { public async onActivate(): Promise<void> {
try { try {
this.isExecuting(true); this.isExecuting(true);
await this.currentCollection.loadOffer();
// passed in options and set by parent as "Settings" by default
this.tabTitle(this.currentCollection.offer() ? "Settings" : "Scale & Settings");
this.options.getPendingNotification.then( const collection: ViewModels.Collection = this.collection as ViewModels.Collection;
(data: DataModels.Notification) => { await collection.loadOffer();
// passed in options and set by parent as "Settings" by default
this.tabTitle(collection.offer() ? "Settings" : "Scale & Settings");
const data: DataModels.Notification = await collection.getPendingThroughputSplitNotification();
this.notification = data; this.notification = data;
this.notificationRead(true); this.notificationRead(true);
}, } catch (error) {
(error) => {
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
this.notification = undefined; this.notification = undefined;
this.notificationRead(true); this.notificationRead(true);
traceFailure( traceFailure(
Action.Tab, Action.Tab,
{ {
databaseAccountName: this.options.collection.container.databaseAccount().name, databaseAccountName: this.collection.container.databaseAccount().name,
databaseName: this.options.collection.databaseId, databaseName: this.collection.databaseId,
collectionName: this.options.collection.id(), collectionName: this.collection.id(),
defaultExperience: this.options.collection.container.defaultExperience(), defaultExperience: this.collection.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab, dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle, tabTitle: this.tabTitle,
error: errorMessage, error: errorMessage,
errorStack: getErrorStack(error), errorStack: getErrorStack(error),
}, },
this.options.onLoadStartKey this.onLoadStartKey
);
logConsoleError(
`Error while fetching container settings for container ${this.options.collection.id()}: ${errorMessage}`
); );
logConsoleError(`Error while fetching container settings for container ${this.collection.id()}: ${errorMessage}`);
throw error; throw error;
}
);
} finally { } finally {
this.offerRead(true); this.offerRead(true);
this.isExecuting(false); this.isExecuting(false);
} }
super.onActivate(); super.onActivate();
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.SettingsV2); this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.CollectionSettingsV2);
} }
}
public getSettingsTabContainer(): Explorer {
return this.getContainer(); export class DatabaseSettingsTabV2 extends SettingsTabV2 {
private notificationRead: ko.Observable<boolean>;
private notification: DataModels.Notification;
constructor(options: ViewModels.TabOptions) {
super(options);
this.tabId = "DatabaseSettingsV2-" + this.tabId;
this.notificationRead = ko.observable(false);
this.settingsComponentAdapter.parameters = ko.computed<boolean>(() => {
if (this.notificationRead()) {
this.pendingNotification(this.notification);
this.notification = undefined;
this.notificationRead(false);
return true;
}
return false;
});
}
public async onActivate(): Promise<void> {
try {
this.isExecuting(true);
const data: DataModels.Notification = await this.database.getPendingThroughputSplitNotification();
this.notification = data;
this.notificationRead(true);
} catch (error) {
const errorMessage = getErrorMessage(error);
this.notification = undefined;
this.notificationRead(true);
traceFailure(
Action.Tab,
{
databaseAccountName: this.database?.container.databaseAccount().name,
databaseName: this.database.id(),
defaultExperience: this.database?.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle,
error: errorMessage,
errorStack: getErrorStack(error),
},
this.onLoadStartKey
);
logConsoleError(`Error while fetching database settings for database ${this.database.id()}: ${errorMessage}`);
throw error;
} finally {
this.isExecuting(false);
}
super.onActivate();
this.database.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettingsV2);
} }
} }

View File

@ -29,7 +29,7 @@ export default class SparkMasterTab extends TabsBase {
this.sparkMasterSrc = ko.observable<string>(sparkMasterEndpoint && sparkMasterEndpoint.endpoint); this.sparkMasterSrc = ko.observable<string>(sparkMasterEndpoint && sparkMasterEndpoint.endpoint);
} }
protected getContainer() { public getContainer() {
return this._container; return this._container;
} }
} }

View File

@ -177,7 +177,7 @@ export default class TabsBase extends WaitsForTemplateViewModel {
return Q(); return Q();
} }
protected getContainer(): Explorer { public getContainer(): Explorer {
return (this.collection && this.collection.container) || (this.database && this.database.container); return (this.collection && this.collection.container) || (this.database && this.database.container);
} }

View File

@ -143,7 +143,11 @@
<!-- /ko --> <!-- /ko -->
<!-- ko if: $data.tabKind === 20 --> <!-- ko if: $data.tabKind === 20 -->
<settings-tab-v2 params="{data: $data}"></settings-tab-v2> <collection-settings-tab-v2 params="{data: $data}"></collection-settings-tab-v2>
<!-- /ko -->
<!-- ko if: $data.tabKind === 21 -->
<database-settings-tab-v2 params="{data: $data}"></database-settings-tab-v2>
<!-- /ko --> <!-- /ko -->
</div> </div>
<!-- /ko --> <!-- /ko -->

View File

@ -56,7 +56,7 @@ export default class TerminalTab extends TabsBase {
}); });
} }
protected getContainer(): Explorer { public getContainer(): Explorer {
return this.container; return this.container;
} }

View File

@ -26,7 +26,7 @@ import MongoQueryTab from "../Tabs/MongoQueryTab";
import MongoShellTab from "../Tabs/MongoShellTab"; import MongoShellTab from "../Tabs/MongoShellTab";
import QueryTab from "../Tabs/QueryTab"; import QueryTab from "../Tabs/QueryTab";
import QueryTablesTab from "../Tabs/QueryTablesTab"; import QueryTablesTab from "../Tabs/QueryTablesTab";
import SettingsTabV2 from "../Tabs/SettingsTabV2"; import { CollectionSettingsTabV2 } from "../Tabs/SettingsTabV2";
import ConflictId from "./ConflictId"; import ConflictId from "./ConflictId";
import DocumentId from "./DocumentId"; import DocumentId from "./DocumentId";
import StoredProcedure from "./StoredProcedure"; import StoredProcedure from "./StoredProcedure";
@ -544,10 +544,12 @@ export default class Collection implements ViewModels.Collection {
}); });
const tabTitle = !this.offer() ? "Settings" : "Scale & Settings"; const tabTitle = !this.offer() ? "Settings" : "Scale & Settings";
const pendingNotificationsPromise: Promise<DataModels.Notification> = this._getPendingThroughputSplitNotification(); const matchingTabs = this.container.tabsManager.getTabs(
const matchingTabs = this.container.tabsManager.getTabs(ViewModels.CollectionTabKind.SettingsV2, (tab) => { ViewModels.CollectionTabKind.CollectionSettingsV2,
(tab) => {
return tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id(); return tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id();
}); }
);
const traceStartData = { const traceStartData = {
databaseAccountName: this.container.databaseAccount().name, databaseAccountName: this.container.databaseAccount().name,
@ -569,26 +571,20 @@ export default class Collection implements ViewModels.Collection {
onUpdateTabsButtons: this.container.onUpdateTabsButtons, onUpdateTabsButtons: this.container.onUpdateTabsButtons,
}; };
let settingsTabV2 = matchingTabs && (matchingTabs[0] as SettingsTabV2); let settingsTabV2 = matchingTabs && (matchingTabs[0] as CollectionSettingsTabV2);
this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions, pendingNotificationsPromise); this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions);
}; };
private launchSettingsTabV2 = ( private launchSettingsTabV2 = (
settingsTabV2: SettingsTabV2, settingsTabV2: CollectionSettingsTabV2,
traceStartData: any, traceStartData: any,
settingsTabOptions: ViewModels.TabOptions, settingsTabOptions: ViewModels.TabOptions
getPendingNotification: Promise<DataModels.Notification>
): void => { ): void => {
const settingsTabV2Options: ViewModels.SettingsTabV2Options = {
...settingsTabOptions,
getPendingNotification: getPendingNotification,
};
if (!settingsTabV2) { if (!settingsTabV2) {
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, traceStartData); const startKey: number = TelemetryProcessor.traceStart(Action.Tab, traceStartData);
settingsTabV2Options.onLoadStartKey = startKey; settingsTabOptions.onLoadStartKey = startKey;
settingsTabV2Options.tabKind = ViewModels.CollectionTabKind.SettingsV2; settingsTabOptions.tabKind = ViewModels.CollectionTabKind.CollectionSettingsV2;
settingsTabV2 = new SettingsTabV2(settingsTabV2Options); settingsTabV2 = new CollectionSettingsTabV2(settingsTabOptions);
this.container.tabsManager.activateNewTab(settingsTabV2); this.container.tabsManager.activateNewTab(settingsTabV2);
} else { } else {
this.container.tabsManager.activateTab(settingsTabV2); this.container.tabsManager.activateTab(settingsTabV2);
@ -973,10 +969,6 @@ export default class Collection implements ViewModels.Collection {
this.uploadFiles(event.originalEvent.dataTransfer.files); this.uploadFiles(event.originalEvent.dataTransfer.files);
} }
public onDeleteCollectionContextMenuClick(source: ViewModels.Collection, event: MouseEvent | KeyboardEvent) {
this.container.deleteCollectionConfirmationPane.open();
}
public uploadFiles = (fileList: FileList): Promise<UploadDetails> => { public uploadFiles = (fileList: FileList): Promise<UploadDetails> => {
// TODO: right now web worker is not working with AAD flow. Use main thread for upload for now until we have backend upload capability // TODO: right now web worker is not working with AAD flow. Use main thread for upload for now until we have backend upload capability
if (configContext.platform === Platform.Hosted && window.authType === AuthType.AAD) { if (configContext.platform === Platform.Hosted && window.authType === AuthType.AAD) {
@ -1044,6 +1036,41 @@ export default class Collection implements ViewModels.Collection {
}); });
}; };
public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
if (!this.container) {
return undefined;
}
try {
const notifications: DataModels.Notification[] = await fetchPortalNotifications();
if (!notifications || notifications.length === 0) {
return undefined;
}
return _.find(notifications, (notification: DataModels.Notification) => {
const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress");
return (
notification.kind === "message" &&
notification.collectionName === this.id() &&
notification.description &&
throughputUpdateRegExp.test(notification.description)
);
});
} catch (error) {
Logger.logError(
JSON.stringify({
error: getErrorMessage(error),
accountName: this.container && this.container.databaseAccount(),
databaseName: this.databaseId,
collectionName: this.id(),
}),
"Settings tree node"
);
return undefined;
}
}
private async _uploadFilesCors(files: FileList): Promise<UploadDetails> { private async _uploadFilesCors(files: FileList): Promise<UploadDetails> {
const data = await Promise.all(Array.from(files).map((file) => this._uploadFile(file))); const data = await Promise.all(Array.from(files).map((file) => this._uploadFile(file)));
@ -1104,37 +1131,6 @@ export default class Collection implements ViewModels.Collection {
} }
} }
private async _getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
if (!this.container) {
return undefined;
}
const throughputUpdateRegExp = new RegExp("Throughput update (.*) in progress");
try {
const notifications = await fetchPortalNotifications();
if (!notifications) {
return undefined;
}
return notifications.find(
({ kind, collectionName, description = "" }) =>
kind === "message" && collectionName === this.id() && throughputUpdateRegExp.test(description)
);
} catch (error) {
Logger.logError(
JSON.stringify({
error: getErrorMessage(error),
accountName: this.container && this.container.databaseAccount(),
databaseName: this.databaseId,
collectionName: this.id(),
}),
"Settings tree node"
);
}
return undefined;
}
private _logUploadDetailsInConsole(uploadDetails: UploadDetails): void { private _logUploadDetailsInConsole(uploadDetails: UploadDetails): void {
const uploadDetailsRecords: UploadDetailsRecord[] = uploadDetails.data; const uploadDetailsRecords: UploadDetailsRecord[] = uploadDetails.data;
const numFiles: number = uploadDetailsRecords.length; const numFiles: number = uploadDetailsRecords.length;

View File

@ -1,11 +1,11 @@
import * as _ from "underscore"; import * as _ from "underscore";
import * as ko from "knockout"; import * as ko from "knockout";
import Q from "q";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import * as DataModels from "../../Contracts/DataModels"; import * as DataModels from "../../Contracts/DataModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import DatabaseSettingsTab from "../Tabs/DatabaseSettingsTab"; import DatabaseSettingsTab from "../Tabs/DatabaseSettingsTab";
import { DatabaseSettingsTabV2 } from "../Tabs/SettingsTabV2";
import Collection from "./Collection"; import Collection from "./Collection";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"; import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
@ -16,7 +16,6 @@ import { readCollections } from "../../Common/dataAccess/readCollections";
import { JunoClient, IJunoResponse } from "../../Juno/JunoClient"; import { JunoClient, IJunoResponse } from "../../Juno/JunoClient";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer"; import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { fetchPortalNotifications } from "../../Common/PortalNotifications"; import { fetchPortalNotifications } from "../../Common/PortalNotifications";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
@ -59,12 +58,17 @@ export default class Database implements ViewModels.Database {
dataExplorerArea: Constants.Areas.ResourceTree, dataExplorerArea: Constants.Areas.ResourceTree,
}); });
const pendingNotificationsPromise: Q.Promise<DataModels.Notification> = this._getPendingThroughputSplitNotification(); const pendingNotificationsPromise: Promise<DataModels.Notification> = this.getPendingThroughputSplitNotification();
const matchingTabs = this.container.tabsManager.getTabs( const useDatabaseSettingsTabV1: boolean = this.container.isFeatureEnabled(
ViewModels.CollectionTabKind.DatabaseSettings, Constants.Features.enableDatabaseSettingsTabV1
(tab) => tab.node?.id() === this.id()
); );
let settingsTab: DatabaseSettingsTab = matchingTabs && (matchingTabs[0] as DatabaseSettingsTab); const tabKind: ViewModels.CollectionTabKind = useDatabaseSettingsTabV1
? ViewModels.CollectionTabKind.DatabaseSettings
: ViewModels.CollectionTabKind.DatabaseSettingsV2;
const matchingTabs = this.container.tabsManager.getTabs(tabKind, (tab) => tab.node?.id() === this.id());
let settingsTab: DatabaseSettingsTab | DatabaseSettingsTabV2 = useDatabaseSettingsTabV1
? (matchingTabs?.[0] as DatabaseSettingsTab)
: (matchingTabs?.[0] as DatabaseSettingsTabV2);
if (!settingsTab) { if (!settingsTab) {
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, { const startKey: number = TelemetryProcessor.traceStart(Action.Tab, {
databaseAccountName: this.container.databaseAccount().name, databaseAccountName: this.container.databaseAccount().name,
@ -75,9 +79,11 @@ export default class Database implements ViewModels.Database {
}); });
pendingNotificationsPromise.then( pendingNotificationsPromise.then(
(data: any) => { (data: any) => {
const pendingNotification: DataModels.Notification = data && data[0]; const pendingNotification: DataModels.Notification = data?.[0];
settingsTab = new DatabaseSettingsTab({ const tabOptions: ViewModels.TabOptions = {
tabKind: ViewModels.CollectionTabKind.DatabaseSettings, tabKind: useDatabaseSettingsTabV1
? ViewModels.CollectionTabKind.DatabaseSettings
: ViewModels.CollectionTabKind.DatabaseSettingsV2,
title: "Scale", title: "Scale",
tabPath: "", tabPath: "",
node: this, node: this,
@ -87,8 +93,10 @@ export default class Database implements ViewModels.Database {
isActive: ko.observable(false), isActive: ko.observable(false),
onLoadStartKey: startKey, onLoadStartKey: startKey,
onUpdateTabsButtons: this.container.onUpdateTabsButtons, onUpdateTabsButtons: this.container.onUpdateTabsButtons,
}); };
settingsTab = useDatabaseSettingsTabV1
? new DatabaseSettingsTab(tabOptions)
: new DatabaseSettingsTabV2(tabOptions);
settingsTab.pendingNotification(pendingNotification); settingsTab.pendingNotification(pendingNotification);
this.container.tabsManager.activateNewTab(settingsTab); this.container.tabsManager.activateNewTab(settingsTab);
}, },
@ -221,20 +229,18 @@ export default class Database implements ViewModels.Database {
} }
} }
private _getPendingThroughputSplitNotification(): Q.Promise<DataModels.Notification> { public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
if (!this.container) { if (!this.container) {
return Q.resolve(undefined); return undefined;
} }
const deferred: Q.Deferred<DataModels.Notification> = Q.defer<DataModels.Notification>(); try {
fetchPortalNotifications().then( const notifications: DataModels.Notification[] = await fetchPortalNotifications();
(notifications) => {
if (!notifications || notifications.length === 0) { if (!notifications || notifications.length === 0) {
deferred.resolve(undefined); return undefined;
return;
} }
const pendingNotification = _.find(notifications, (notification: DataModels.Notification) => { return _.find(notifications, (notification: DataModels.Notification) => {
const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress"); const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress");
return ( return (
notification.kind === "message" && notification.kind === "message" &&
@ -244,10 +250,7 @@ export default class Database implements ViewModels.Database {
throughputUpdateRegExp.test(notification.description) throughputUpdateRegExp.test(notification.description)
); );
}); });
} catch (error) {
deferred.resolve(pendingNotification);
},
(error: any) => {
Logger.logError( Logger.logError(
JSON.stringify({ JSON.stringify({
error: getErrorMessage(error), error: getErrorMessage(error),
@ -257,11 +260,9 @@ export default class Database implements ViewModels.Database {
}), }),
"Settings tree node" "Settings tree node"
); );
deferred.resolve(undefined);
}
);
return deferred.promise; return undefined;
}
} }
private getDeltaCollections( private getDeltaCollections(

View File

@ -15,12 +15,13 @@ import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
import RefreshIcon from "../../../images/refresh-cosmos.svg"; import RefreshIcon from "../../../images/refresh-cosmos.svg";
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg"; import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
import FileIcon from "../../../images/notebook/file-cosmos.svg"; import FileIcon from "../../../images/notebook/file-cosmos.svg";
import PublishIcon from "../../../images/notebook/publish_content.svg";
import { ArrayHashMap } from "../../Common/ArrayHashMap"; import { ArrayHashMap } from "../../Common/ArrayHashMap";
import { NotebookUtil } from "../Notebook/NotebookUtil"; import { NotebookUtil } from "../Notebook/NotebookUtil";
import _ from "underscore"; import _ from "underscore";
import { IPinnedRepo } from "../../Juno/JunoClient"; import { IPinnedRepo } from "../../Juno/JunoClient";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas } from "../../Common/Constants"; import { Areas } from "../../Common/Constants";
import * as GitHubUtils from "../../Utils/GitHubUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils";
import GalleryIcon from "../../../images/GalleryIcon.svg"; import GalleryIcon from "../../../images/GalleryIcon.svg";
@ -716,6 +717,23 @@ export class ResourceTreeAdapter implements ReactAdapter {
}, },
]; ];
if (this.container.isGalleryPublishEnabled() && item.type === NotebookContentItemType.Notebook) {
items.push({
label: "Publish to gallery",
iconSrc: PublishIcon,
onClick: async () => {
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
source: Source.ResourceTreeMenu,
});
const content = await this.container.readFile(item);
if (content) {
await this.container.publishNotebook(item.name, content);
}
},
});
}
// "Copy to ..." isn't needed if github locations are not available // "Copy to ..." isn't needed if github locations are not available
if (!this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { if (!this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
items = items.filter((item) => item.label !== "Copy to ..."); items = items.filter((item) => item.label !== "Copy to ...");

View File

@ -0,0 +1,5 @@
@import "../../less/Common/Constants";
.standalone-gallery-root {
background: @GalleryBackgroundColor;
}

View File

@ -1,9 +1,10 @@
import "bootstrap/dist/css/bootstrap.css"; import "bootstrap/dist/css/bootstrap.css";
import "./GalleryViewer.less";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons"; import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { Text, Link } from "office-ui-fabric-react"; import { Text, Link } from "office-ui-fabric-react";
import * as React from "react"; import * as React from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import { initializeConfiguration } from "../ConfigContext"; import { configContext, initializeConfiguration } from "../ConfigContext";
import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent"; import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent";
import { import {
GalleryAndNotebookViewerComponent, GalleryAndNotebookViewerComponent,
@ -24,6 +25,7 @@ const onInit = async () => {
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search); const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
const props: GalleryAndNotebookViewerComponentProps = { const props: GalleryAndNotebookViewerComponentProps = {
isGalleryPublishEnabled: configContext.ENABLE_GALLERY_PUBLISH,
junoClient: new JunoClient(), junoClient: new JunoClient(),
selectedTab: galleryViewerProps.selectedTab || GalleryTab.OfficialSamples, selectedTab: galleryViewerProps.selectedTab || GalleryTab.OfficialSamples,
sortBy: galleryViewerProps.sortBy || SortBy.MostViewed, sortBy: galleryViewerProps.sortBy || SortBy.MostViewed,
@ -31,7 +33,7 @@ const onInit = async () => {
}; };
const element = ( const element = (
<> <div className="standalone-gallery-root">
<header> <header>
<GalleryHeaderComponent /> <GalleryHeaderComponent />
</header> </header>
@ -52,7 +54,7 @@ const onInit = async () => {
<GalleryAndNotebookViewerComponent {...props} /> <GalleryAndNotebookViewerComponent {...props} />
</div> </div>
</> </div>
); );
ReactDOM.render(element, document.getElementById("galleryContent")); ReactDOM.render(element, document.getElementById("galleryContent"));

View File

@ -225,7 +225,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith( expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/downloads`, `${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/${id}/downloads`,
{ {
method: "PATCH", method: "PATCH",
headers: { headers: {
@ -248,7 +248,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith( expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleSubscriptionId}/${sampleDatabaseAccount.name}/gallery/${id}/favorite`, `${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
{ {
method: "PATCH", method: "PATCH",
headers: { headers: {
@ -270,13 +270,16 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/unfavorite`, { expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/${id}/unfavorite`,
{
method: "PATCH", method: "PATCH",
headers: { headers: {
[authorizationHeader.header]: authorizationHeader.token, [authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json", [HttpHeaders.contentType]: "application/json",
}, },
}); }
);
}); });
it("getFavoriteNotebooks", async () => { it("getFavoriteNotebooks", async () => {
@ -289,12 +292,15 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/favorites`, { expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/favorites`,
{
headers: { headers: {
[authorizationHeader.header]: authorizationHeader.token, [authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json", [HttpHeaders.contentType]: "application/json",
}, },
}); }
);
}); });
it("getPublishedNotebooks", async () => { it("getPublishedNotebooks", async () => {
@ -308,7 +314,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith( expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleSubscriptionId}/gallery/published`, `${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/published`,
{ {
headers: { headers: {
[authorizationHeader.header]: authorizationHeader.token, [authorizationHeader.header]: authorizationHeader.token,
@ -329,13 +335,16 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`, { expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/${id}`,
{
method: "DELETE", method: "DELETE",
headers: { headers: {
[authorizationHeader.header]: authorizationHeader.token, [authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json", [HttpHeaders.contentType]: "application/json",
}, },
}); }
);
}); });
it("publishNotebook", async () => { it("publishNotebook", async () => {
@ -364,7 +373,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith( expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleSubscriptionId}/${sampleDatabaseAccount.name}/gallery`, `${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery`,
{ {
method: "PUT", method: "PUT",
headers: { headers: {

View File

@ -186,10 +186,7 @@ export class JunoClient {
public async getPublicGalleryData(): Promise<IJunoResponse<IPublicGalleryData>> { public async getPublicGalleryData(): Promise<IJunoResponse<IPublicGalleryData>> {
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/public`; const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/public`;
const response = await window.fetch(url, { const response = await window.fetch(url, { headers: JunoClient.getHeaders() });
method: "PATCH",
headers: JunoClient.getHeaders(),
});
let data: IPublicGalleryData; let data: IPublicGalleryData;
if (response.status === HttpStatusCodes.OK) { if (response.status === HttpStatusCodes.OK) {
@ -222,10 +219,7 @@ export class JunoClient {
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> { public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/isCodeOfConductAccepted`; const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/isCodeOfConductAccepted`;
const response = await window.fetch(url, { const response = await window.fetch(url, { headers: JunoClient.getHeaders() });
method: "PATCH",
headers: JunoClient.getHeaders(),
});
let data: boolean; let data: boolean;
if (response.status === HttpStatusCodes.OK) { if (response.status === HttpStatusCodes.OK) {
@ -283,7 +277,7 @@ export class JunoClient {
} }
public async increaseNotebookDownloadCount(id: string): Promise<IJunoResponse<IGalleryItem>> { public async increaseNotebookDownloadCount(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/downloads`, { const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/downloads`, {
method: "PATCH", method: "PATCH",
headers: JunoClient.getHeaders(), headers: JunoClient.getHeaders(),
}); });
@ -317,7 +311,7 @@ export class JunoClient {
} }
public async unfavoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> { public async unfavoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/unfavorite`, { const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/unfavorite`, {
method: "PATCH", method: "PATCH",
headers: JunoClient.getHeaders(), headers: JunoClient.getHeaders(),
}); });
@ -334,19 +328,19 @@ export class JunoClient {
} }
public async getFavoriteNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> { public async getFavoriteNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return await this.getNotebooks(`${this.getNotebooksUrl()}/gallery/favorites`, { return await this.getNotebooks(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/favorites`, {
headers: JunoClient.getHeaders(), headers: JunoClient.getHeaders(),
}); });
} }
public async getPublishedNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> { public async getPublishedNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
return await this.getNotebooks(`${this.getNotebooksUrl()}/${this.getSubscriptionId()}/gallery/published`, { return await this.getNotebooks(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/published`, {
headers: JunoClient.getHeaders(), headers: JunoClient.getHeaders(),
}); });
} }
public async deleteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> { public async deleteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}`, { const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}`, {
method: "DELETE", method: "DELETE",
headers: JunoClient.getHeaders(), headers: JunoClient.getHeaders(),
}); });
@ -501,12 +495,8 @@ export class JunoClient {
return userContext.subscriptionId; return userContext.subscriptionId;
} }
private getNotebooksAccountUrl(): string {
return `${this.getNotebooksUrl()}/${this.getAccount()}`;
}
private getNotebooksSubscriptionIdAccountUrl(): string { private getNotebooksSubscriptionIdAccountUrl(): string {
return `${this.getNotebooksUrl()}/${this.getSubscriptionId()}/${this.getAccount()}`; return `${this.getNotebooksUrl()}/subscriptions/${this.getSubscriptionId()}/databaseAccounts/${this.getAccount()}`;
} }
private getAnalyticsUrl(): string { private getAnalyticsUrl(): string {

View File

@ -14,6 +14,7 @@ import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less";
import "./Explorer/Controls/DynamicList/DynamicListComponent.less"; import "./Explorer/Controls/DynamicList/DynamicListComponent.less";
import "./Explorer/Controls/JsonEditor/JsonEditorComponent.less"; import "./Explorer/Controls/JsonEditor/JsonEditorComponent.less";
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less"; import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
import "./Explorer/Panes/PanelComponent.less";
import "../less/TableStyles/queryBuilder.less"; import "../less/TableStyles/queryBuilder.less";
import "../externals/jquery.dataTables.min.css"; import "../externals/jquery.dataTables.min.css";
import "../less/TableStyles/fulldatatables.less"; import "../less/TableStyles/fulldatatables.less";
@ -64,7 +65,9 @@ import arrowLeftImg from "../images/imgarrowlefticon.svg";
import { KOCommentEnd, KOCommentIfStart } from "./koComment"; import { KOCommentEnd, KOCommentIfStart } from "./koComment";
import { useConfig } from "./hooks/useConfig"; import { useConfig } from "./hooks/useConfig";
import { useKnockoutExplorer } from "./hooks/useKnockoutExplorer"; import { useKnockoutExplorer } from "./hooks/useKnockoutExplorer";
import { useSidePanel } from "./hooks/useSidePanel";
import { NotificationConsoleComponent } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; import { NotificationConsoleComponent } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { PanelContainerComponent } from "./Explorer/Panes/PanelContainerComponent";
initializeIcons(); initializeIcons();
@ -73,10 +76,15 @@ const App: React.FunctionComponent = () => {
const [notificationConsoleData, setNotificationConsoleData] = useState(undefined); const [notificationConsoleData, setNotificationConsoleData] = useState(undefined);
//TODO: Refactor so we don't need to pass the id to remove a console data //TODO: Refactor so we don't need to pass the id to remove a console data
const [inProgressConsoleDataIdToBeDeleted, setInProgressConsoleDataIdToBeDeleted] = useState(""); const [inProgressConsoleDataIdToBeDeleted, setInProgressConsoleDataIdToBeDeleted] = useState("");
const { isPanelOpen, panelContent, headerText, openSidePanel, closeSidePanel } = useSidePanel();
const explorerParams: ExplorerParams = { const explorerParams: ExplorerParams = {
setIsNotificationConsoleExpanded, setIsNotificationConsoleExpanded,
setNotificationConsoleData, setNotificationConsoleData,
setInProgressConsoleDataIdToBeDeleted, setInProgressConsoleDataIdToBeDeleted,
openSidePanel,
closeSidePanel,
}; };
const config = useConfig(); const config = useConfig();
useKnockoutExplorer(config, explorerParams); useKnockoutExplorer(config, explorerParams);
@ -309,6 +317,13 @@ const App: React.FunctionComponent = () => {
</div> </div>
</div> </div>
{/* Global loader - End */} {/* Global loader - End */}
<PanelContainerComponent
isOpen={isPanelOpen}
panelContent={panelContent}
headerText={headerText}
closePanel={closeSidePanel}
isConsoleExpanded={isNotificationConsoleExpanded}
/>
<div data-bind="react:uploadItemsPaneAdapter" /> <div data-bind="react:uploadItemsPaneAdapter" />
<div data-bind='component: { name: "add-database-pane", params: {data: addDatabasePane} }' /> <div data-bind='component: { name: "add-database-pane", params: {data: addDatabasePane} }' />
<div data-bind='component: { name: "add-collection-pane", params: { data: addCollectionPane} }' /> <div data-bind='component: { name: "add-collection-pane", params: { data: addCollectionPane} }' />

View File

@ -92,6 +92,28 @@ export enum Action {
SettingsV2Updated, SettingsV2Updated,
SettingsV2Discarded, SettingsV2Discarded,
MongoIndexUpdated, MongoIndexUpdated,
NotebooksGalleryPublish,
NotebooksGalleryReportAbuse,
NotebooksGalleryClickReportAbuse,
NotebooksGalleryViewCodeOfConduct,
NotebooksGalleryAcceptCodeOfConduct,
NotebooksGalleryFavorite,
NotebooksGalleryUnfavorite,
NotebooksGalleryClickDelete,
NotebooksGalleryDelete,
NotebooksGalleryClickDownload,
NotebooksGalleryDownload,
NotebooksGalleryViewNotebook,
NotebooksGalleryViewGallery,
NotebooksGalleryViewOfficialSamples,
NotebooksGalleryViewPublicGallery,
NotebooksGalleryViewFavorites,
NotebooksGalleryViewPublishedNotebooks,
NotebooksGalleryClickPublishToGallery,
NotebooksGalleryOfficialSamplesCount,
NotebooksGalleryPublicGalleryCount,
NotebooksGalleryFavoritesCount,
NotebooksGalleryPublishedCount,
} }
export const ActionModifiers = { export const ActionModifiers = {
@ -134,3 +156,8 @@ export enum SourceBlade {
ScriptExplorer, ScriptExplorer,
Keys, Keys,
} }
export enum Source {
ResourceTreeMenu = "ResourceTreeMenu",
CommandBarMenu = "CommandBarMenu",
}

View File

@ -12,7 +12,7 @@ import { getDataExplorerWindow } from "../../Utils/WindowUtils";
type TelemetryData = { [key: string]: unknown }; type TelemetryData = { [key: string]: unknown };
export function trace(action: Action, actionModifier: string = ActionModifiers.Mark, data?: TelemetryData): void { export function trace(action: Action, actionModifier: string = ActionModifiers.Mark, data: TelemetryData = {}): void {
sendMessage({ sendMessage({
type: MessageTypes.TelemetryInfo, type: MessageTypes.TelemetryInfo,
data: { data: {

View File

@ -7,10 +7,12 @@ import {
GalleryViewerComponent, GalleryViewerComponent,
} from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent"; } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import Explorer from "../Explorer/Explorer"; import Explorer from "../Explorer/Explorer";
import { IChoiceGroupOption, IChoiceGroupProps } from "office-ui-fabric-react"; import { IChoiceGroupOption, IChoiceGroupProps, IProgressIndicatorProps } from "office-ui-fabric-react";
import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent"; import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent";
import { handleError } from "../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack, handleError } from "../Common/ErrorHandlingUtils";
import { HttpStatusCodes } from "../Common/Constants"; import { HttpStatusCodes } from "../Common/Constants";
import { trace, traceFailure, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
const defaultSelectedAbuseCategory = "Other"; const defaultSelectedAbuseCategory = "Other";
const abuseCategories: IChoiceGroupOption[] = [ const abuseCategories: IChoiceGroupOption[] = [
@ -81,6 +83,14 @@ export interface GalleryViewerProps {
} }
export interface DialogHost { export interface DialogHost {
showOkModalDialog(
title: string,
msg: string,
okLabel: string,
onOk: () => void,
progressIndicatorProps?: IProgressIndicatorProps
): void;
showOkCancelModalDialog( showOkCancelModalDialog(
title: string, title: string,
msg: string, msg: string,
@ -88,8 +98,10 @@ export interface DialogHost {
onOk: () => void, onOk: () => void,
cancelLabel: string, cancelLabel: string,
onCancel: () => void, onCancel: () => void,
progressIndicatorProps?: IProgressIndicatorProps,
choiceGroupProps?: IChoiceGroupProps, choiceGroupProps?: IChoiceGroupProps,
textFieldProps?: TextFieldProps textFieldProps?: TextFieldProps,
primaryButtonDisabled?: boolean
): void; ): void;
} }
@ -99,6 +111,8 @@ export function reportAbuse(
dialogHost: DialogHost, dialogHost: DialogHost,
onComplete: (success: boolean) => void onComplete: (success: boolean) => void
): void { ): void {
trace(Action.NotebooksGalleryClickReportAbuse, ActionModifiers.Mark, { notebookId: data.id });
const notebookId = data.id; const notebookId = data.id;
let abuseCategory = defaultSelectedAbuseCategory; let abuseCategory = defaultSelectedAbuseCategory;
let additionalDetails: string; let additionalDetails: string;
@ -108,32 +122,72 @@ export function reportAbuse(
undefined, undefined,
"Report Abuse", "Report Abuse",
async () => { async () => {
const clearSubmitReportNotification = NotificationConsoleUtils.logConsoleProgress( dialogHost.showOkCancelModalDialog(
`Submitting your report on ${data.name} violating code of conduct` "Report Abuse",
`Submitting your report on ${data.name} violating code of conduct`,
"Reporting...",
undefined,
"Cancel",
undefined,
{},
undefined,
undefined,
true
); );
const startKey = traceStart(Action.NotebooksGalleryReportAbuse, { notebookId: data.id, abuseCategory });
try { try {
const response = await junoClient.reportAbuse(notebookId, abuseCategory, additionalDetails); const response = await junoClient.reportAbuse(notebookId, abuseCategory, additionalDetails);
if (response.status !== HttpStatusCodes.Accepted) { if (response.status !== HttpStatusCodes.Accepted) {
throw new Error(`Received HTTP ${response.status} when submitting report for ${data.name}`); throw new Error(`Received HTTP ${response.status} when submitting report for ${data.name}`);
} }
NotificationConsoleUtils.logConsoleInfo( dialogHost.showOkModalDialog(
`Your report on ${data.name} has been submitted. Thank you for reporting the violation.` "Report Abuse",
`Your report on ${data.name} has been submitted. Thank you for reporting the violation.`,
"OK",
undefined,
{
percentComplete: 1,
}
); );
traceSuccess(Action.NotebooksGalleryReportAbuse, { notebookId: data.id, abuseCategory }, startKey);
onComplete(response.data); onComplete(response.data);
} catch (error) { } catch (error) {
traceFailure(
Action.NotebooksGalleryReportAbuse,
{
notebookId: data.id,
abuseCategory,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
handleError( handleError(
error, error,
"GalleryUtils/reportAbuse", "GalleryUtils/reportAbuse",
`Failed to submit report on ${data.name} violating code of conduct` `Failed to submit report on ${data.name} violating code of conduct`
); );
}
clearSubmitReportNotification(); dialogHost.showOkModalDialog(
"Report Abuse",
`Failed to submit report on ${data.name} violating code of conduct`,
"OK",
undefined,
{
percentComplete: 1,
}
);
}
}, },
"Cancel", "Cancel",
undefined, undefined,
undefined,
{ {
label: "How does this content violate the code of conduct?", label: "How does this content violate the code of conduct?",
options: abuseCategories, options: abuseCategories,
@ -160,6 +214,12 @@ export function downloadItem(
data: IGalleryItem, data: IGalleryItem,
onComplete: (item: IGalleryItem) => void onComplete: (item: IGalleryItem) => void
): void { ): void {
trace(Action.NotebooksGalleryClickDownload, ActionModifiers.Mark, {
notebookId: data.id,
downloadCount: data.downloads,
isSample: data.isSample,
});
const name = data.name; const name = data.name;
container.showOkCancelModalDialog( container.showOkCancelModalDialog(
"Download to My Notebooks", "Download to My Notebooks",
@ -171,6 +231,12 @@ export function downloadItem(
`Downloading ${name} to My Notebooks` `Downloading ${name} to My Notebooks`
); );
const startKey = traceStart(Action.NotebooksGalleryDownload, {
notebookId: data.id,
downloadCount: data.downloads,
isSample: data.isSample,
});
try { try {
const response = await junoClient.getNotebookContent(data.id); const response = await junoClient.getNotebookContent(data.id);
if (!response.data) { if (!response.data) {
@ -185,9 +251,26 @@ export function downloadItem(
const increaseDownloadResponse = await junoClient.increaseNotebookDownloadCount(data.id); const increaseDownloadResponse = await junoClient.increaseNotebookDownloadCount(data.id);
if (increaseDownloadResponse.data) { if (increaseDownloadResponse.data) {
traceSuccess(
Action.NotebooksGalleryDownload,
{ notebookId: data.id, downloadCount: increaseDownloadResponse.data.downloads, isSample: data.isSample },
startKey
);
onComplete(increaseDownloadResponse.data); onComplete(increaseDownloadResponse.data);
} }
} catch (error) { } catch (error) {
traceFailure(
Action.NotebooksGalleryDownload,
{
notebookId: data.id,
downloadCount: data.downloads,
isSample: data.isSample,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
handleError(error, "GalleryUtils/downloadItem", `Failed to download ${data.name}`); handleError(error, "GalleryUtils/downloadItem", `Failed to download ${data.name}`);
} }
@ -205,14 +288,38 @@ export async function favoriteItem(
onComplete: (item: IGalleryItem) => void onComplete: (item: IGalleryItem) => void
): Promise<void> { ): Promise<void> {
if (container) { if (container) {
const startKey = traceStart(Action.NotebooksGalleryFavorite, {
notebookId: data.id,
isSample: data.isSample,
favoriteCount: data.favorites,
});
try { try {
const response = await junoClient.favoriteNotebook(data.id); const response = await junoClient.favoriteNotebook(data.id);
if (!response.data) { if (!response.data) {
throw new Error(`Received HTTP ${response.status} when favoriting ${data.name}`); throw new Error(`Received HTTP ${response.status} when favoriting ${data.name}`);
} }
traceSuccess(
Action.NotebooksGalleryFavorite,
{ notebookId: data.id, isSample: data.isSample, favoriteCount: response.data.favorites },
startKey
);
onComplete(response.data); onComplete(response.data);
} catch (error) { } catch (error) {
traceFailure(
Action.NotebooksGalleryFavorite,
{
notebookId: data.id,
isSample: data.isSample,
favoriteCount: data.favorites,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
handleError(error, "GalleryUtils/favoriteItem", `Failed to favorite ${data.name}`); handleError(error, "GalleryUtils/favoriteItem", `Failed to favorite ${data.name}`);
} }
} }
@ -225,14 +332,38 @@ export async function unfavoriteItem(
onComplete: (item: IGalleryItem) => void onComplete: (item: IGalleryItem) => void
): Promise<void> { ): Promise<void> {
if (container) { if (container) {
const startKey = traceStart(Action.NotebooksGalleryUnfavorite, {
notebookId: data.id,
isSample: data.isSample,
favoriteCount: data.favorites,
});
try { try {
const response = await junoClient.unfavoriteNotebook(data.id); const response = await junoClient.unfavoriteNotebook(data.id);
if (!response.data) { if (!response.data) {
throw new Error(`Received HTTP ${response.status} when unfavoriting ${data.name}`); throw new Error(`Received HTTP ${response.status} when unfavoriting ${data.name}`);
} }
traceSuccess(
Action.NotebooksGalleryUnfavorite,
{ notebookId: data.id, isSample: data.isSample, favoriteCount: response.data.favorites },
startKey
);
onComplete(response.data); onComplete(response.data);
} catch (error) { } catch (error) {
traceFailure(
Action.NotebooksGalleryUnfavorite,
{
notebookId: data.id,
isSample: data.isSample,
favoriteCount: data.favorites,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
handleError(error, "GalleryUtils/unfavoriteItem", `Failed to unfavorite ${data.name}`); handleError(error, "GalleryUtils/unfavoriteItem", `Failed to unfavorite ${data.name}`);
} }
} }
@ -245,6 +376,8 @@ export function deleteItem(
onComplete: (item: IGalleryItem) => void onComplete: (item: IGalleryItem) => void
): void { ): void {
if (container) { if (container) {
trace(Action.NotebooksGalleryClickDelete, ActionModifiers.Mark, { notebookId: data.id });
container.showOkCancelModalDialog( container.showOkCancelModalDialog(
"Remove published notebook", "Remove published notebook",
`Would you like to remove ${data.name} from the gallery?`, `Would you like to remove ${data.name} from the gallery?`,
@ -256,15 +389,25 @@ export function deleteItem(
`Removing ${name} from gallery` `Removing ${name} from gallery`
); );
const startKey = traceStart(Action.NotebooksGalleryDelete, { notebookId: data.id });
try { try {
const response = await junoClient.deleteNotebook(data.id); const response = await junoClient.deleteNotebook(data.id);
if (!response.data) { if (!response.data) {
throw new Error(`Received HTTP ${response.status} while removing ${name}`); throw new Error(`Received HTTP ${response.status} while removing ${name}`);
} }
traceSuccess(Action.NotebooksGalleryDelete, { notebookId: data.id }, startKey);
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully removed ${name} from gallery`); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully removed ${name} from gallery`);
onComplete(response.data); onComplete(response.data);
} catch (error) { } catch (error) {
traceFailure(
Action.NotebooksGalleryDelete,
{ notebookId: data.id, error: getErrorMessage(error), errorStack: getErrorStack(error) },
startKey
);
handleError(error, "GalleryUtils/deleteItem", `Failed to remove ${name} from gallery`); handleError(error, "GalleryUtils/deleteItem", `Failed to remove ${name} from gallery`);
} }

29
src/hooks/useSidePanel.ts Normal file
View File

@ -0,0 +1,29 @@
import { useState } from "react";
export interface SidePanelHooks {
isPanelOpen: boolean;
panelContent: JSX.Element;
headerText: string;
openSidePanel: (headerText: string, panelContent: JSX.Element) => void;
closeSidePanel: () => void;
}
export const useSidePanel = (): SidePanelHooks => {
const [isPanelOpen, setIsPanelOpen] = useState<boolean>(false);
const [panelContent, setPanelContent] = useState<JSX.Element>();
const [headerText, setHeaderText] = useState<string>();
const openSidePanel = (headerText: string, panelContent: JSX.Element): void => {
setHeaderText(headerText);
setPanelContent(panelContent);
setIsPanelOpen(true);
};
const closeSidePanel = (): void => {
setHeaderText("");
setPanelContent(undefined);
setIsPanelOpen(false);
};
return { isPanelOpen, panelContent, headerText, openSidePanel, closeSidePanel };
};

View File

@ -36,7 +36,8 @@
<div> <div>
<a class="feedbackstyle" href="https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20Emulator%20Feedback"> <a class="feedbackstyle" href="https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20Emulator%20Feedback">
<img id="imgiconwidth1" src="/Feedback.svg" alt="Send feedback" /> <span class="menuExplorer">Feedback</span> <img id="imgiconwidth1" src="/Feedback.svg" alt="Report Issue" />
<span class="menuExplorer">Report Issue</span>
</a> </a>
</div> </div>
</nav> </nav>

View File

@ -82,12 +82,12 @@ describe("Collection Add and Delete Cassandra spec", () => {
await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]'); await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]');
// confirm delete container // confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true }); await frame.waitFor('input[id="confirmCollectionId"]', { visible: true });
await frame.type('input[data-test="confirmCollectionId"]', textId); await frame.type('input[id="confirmCollectionId"]', textId);
// click delete // click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true }); await frame.waitFor('button[id="sidePanelOkButton"]', { visible: true });
await frame.click('input[data-test="deleteCollection"]'); await frame.click('button[id="sidePanelOkButton"]');
await frame.waitFor(LOADING_STATE_DELAY); await frame.waitFor(LOADING_STATE_DELAY);
await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true }); await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true });

View File

@ -98,12 +98,12 @@ describe("Collection Add and Delete Mongo spec", () => {
await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]'); await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]');
// confirm delete container // confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true }); await frame.waitFor('input[id="confirmCollectionId"]', { visible: true });
await frame.type('input[data-test="confirmCollectionId"]', textId); await frame.type('input[id="confirmCollectionId"]', textId);
// click delete // click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true }); await frame.waitFor('button[id="sidePanelOkButton"]', { visible: true });
await frame.click('input[data-test="deleteCollection"]'); await frame.click('button[id="sidePanelOkButton"]');
await frame.waitFor(LOADING_STATE_DELAY); await frame.waitFor(LOADING_STATE_DELAY);
await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true }); await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true });

View File

@ -0,0 +1,21 @@
import { Frame } from "puppeteer";
import { ApiKind } from "../../src/Contracts/DataModels";
import { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils";
jest.setTimeout(300000);
let frame: Frame;
describe("Mongo", () => {
it("Account opens", async () => {
try {
frame = await getTestExplorerFrame(ApiKind.MongoDB);
await frame.waitForSelector(".accordion");
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const testName = (expect as any).getState().currentTestName;
await page.screenshot({ path: `Test Failed ${testName}.jpg` });
throw error;
}
});
});

View File

@ -2,6 +2,7 @@ import { Frame } from "puppeteer";
import { TestExplorerParams } from "../testExplorer/TestExplorerParams"; import { TestExplorerParams } from "../testExplorer/TestExplorerParams";
import { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils"; import { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils";
import { SelfServeType } from "../../src/SelfServe/SelfServeUtils"; import { SelfServeType } from "../../src/SelfServe/SelfServeUtils";
import { ApiKind } from "../../src/Contracts/DataModels";
jest.setTimeout(300000); jest.setTimeout(300000);
@ -10,9 +11,13 @@ describe("Self Serve", () => {
it("Launch Self Serve Example", async () => { it("Launch Self Serve Example", async () => {
try { try {
frame = await getTestExplorerFrame( frame = await getTestExplorerFrame(
ApiKind.SQL,
new Map<string, string>([[TestExplorerParams.selfServeType, SelfServeType.example]]) new Map<string, string>([[TestExplorerParams.selfServeType, SelfServeType.example]])
); );
// wait for refresh RP call to end
await frame.waitFor(10000);
// id of the display element is in the format {PROPERTY_NAME}-{DISPLAY_NAME}-{DISPLAY_TYPE} // id of the display element is in the format {PROPERTY_NAME}-{DISPLAY_NAME}-{DISPLAY_TYPE}
await frame.waitForSelector("#description-text-display"); await frame.waitForSelector("#description-text-display");

View File

@ -101,12 +101,12 @@ describe("Collection Add and Delete SQL spec", () => {
await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]'); await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]');
// confirm delete container // confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true }); await frame.waitFor('input[id="confirmCollectionId"]', { visible: true });
await frame.type('input[data-test="confirmCollectionId"]', textId); await frame.type('input[id="confirmCollectionId"]', textId);
// click delete // click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true }); await frame.waitFor('button[id="sidePanelOkButton"]', { visible: true });
await frame.click('input[data-test="deleteCollection"]'); await frame.click('button[id="sidePanelOkButton"]');
await frame.waitFor(LOADING_STATE_DELAY); await frame.waitFor(LOADING_STATE_DELAY);
await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true }); await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true });

View File

@ -68,12 +68,12 @@ describe("Collection Add and Delete Tables spec", () => {
await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]'); await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]');
// confirm delete container // confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true }); await frame.waitFor('input[id="confirmCollectionId"]', { visible: true });
await frame.type('input[data-test="confirmCollectionId"]', textId); await frame.type('input[id="confirmCollectionId"]', textId);
// click delete // click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true }); await frame.waitFor('button[id="sidePanelOkButton"]', { visible: true });
await frame.click('input[data-test="deleteCollection"]'); await frame.click('button[id="sidePanelOkButton"]');
await frame.waitFor(LOADING_STATE_DELAY); await frame.waitFor(LOADING_STATE_DELAY);
await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true }); await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true });
await frame.waitFor(LOADING_STATE_DELAY); await frame.waitFor(LOADING_STATE_DELAY);

View File

@ -1,9 +1,9 @@
import "../../less/hostedexplorer.less"; import "../../less/hostedexplorer.less";
import { TestExplorerParams } from "./TestExplorerParams"; import { TestExplorerParams } from "./TestExplorerParams";
import { DatabaseAccountsGetResponse } from "@azure/arm-cosmosdb/esm/models";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb"; import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import * as msRest from "@azure/ms-rest-js"; import * as msRest from "@azure/ms-rest-js";
import * as ViewModels from "../../src/Contracts/ViewModels"; import * as ViewModels from "../../src/Contracts/ViewModels";
import { Capability, DatabaseAccount } from "../../src/Contracts/DataModels";
class CustomSigner implements msRest.ServiceClientCredentials { class CustomSigner implements msRest.ServiceClientCredentials {
private token: string; private token: string;
@ -22,9 +22,32 @@ const getDatabaseAccount = async (
notebooksAccountSubscriptonId: string, notebooksAccountSubscriptonId: string,
notebooksAccountResourceGroup: string, notebooksAccountResourceGroup: string,
notebooksAccountName: string notebooksAccountName: string
): Promise<DatabaseAccountsGetResponse> => { ): Promise<DatabaseAccount> => {
const client = new CosmosDBManagementClient(new CustomSigner(token), notebooksAccountSubscriptonId); const client = new CosmosDBManagementClient(new CustomSigner(token), notebooksAccountSubscriptonId);
return client.databaseAccounts.get(notebooksAccountResourceGroup, notebooksAccountName); const databaseAccountGetResponse = await client.databaseAccounts.get(
notebooksAccountResourceGroup,
notebooksAccountName
);
const databaseAccount: DatabaseAccount = {
id: databaseAccountGetResponse.id,
name: databaseAccountGetResponse.name,
location: databaseAccountGetResponse.location,
type: databaseAccountGetResponse.type,
kind: databaseAccountGetResponse.kind,
tags: databaseAccountGetResponse.tags,
properties: {
documentEndpoint: databaseAccountGetResponse.documentEndpoint,
tableEndpoint: undefined,
gremlinEndpoint: undefined,
cassandraEndpoint: undefined,
capabilities: databaseAccountGetResponse.capabilities.map((capability) => {
return { name: capability.name } as Capability;
}),
},
};
return databaseAccount;
}; };
const initTestExplorer = async (): Promise<void> => { const initTestExplorer = async (): Promise<void> => {
@ -55,7 +78,7 @@ const initTestExplorer = async (): Promise<void> => {
subscriptionId: portalRunnerSubscripton, subscriptionId: portalRunnerSubscripton,
resourceGroup: portalRunnerResourceGroup, resourceGroup: portalRunnerResourceGroup,
authorizationToken: `Bearer ${token}`, authorizationToken: `Bearer ${token}`,
features: {}, features: { sampleFeature: "sampleFeatureValue" },
hasWriteAccess: true, hasWriteAccess: true,
csmEndpoint: "https://management.azure.com", csmEndpoint: "https://management.azure.com",
dnsSuffix: "documents.azure.com", dnsSuffix: "documents.azure.com",

View File

@ -1,18 +1,30 @@
import { Frame } from "puppeteer"; import { Frame } from "puppeteer";
import { TestExplorerParams } from "./TestExplorerParams"; import { TestExplorerParams } from "./TestExplorerParams";
import { ClientSecretCredential } from "@azure/identity"; import { ClientSecretCredential } from "@azure/identity";
import { ApiKind } from "../../src/Contracts/DataModels";
let testExplorerFrame: Frame; let testExplorerFrame: Frame;
export const getTestExplorerFrame = async (params?: Map<string, string>): Promise<Frame> => { export const getTestExplorerFrame = async (apiKind?: ApiKind, params?: Map<string, string>): Promise<Frame> => {
if (testExplorerFrame) { if (testExplorerFrame) {
return testExplorerFrame; return testExplorerFrame;
} }
let portalRunnerDatabaseAccount: string;
let portalRunnerDatabaseAccountKey: string;
switch (apiKind) {
case ApiKind.MongoDB:
portalRunnerDatabaseAccount = process.env.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT;
portalRunnerDatabaseAccountKey = process.env.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY;
break;
default:
portalRunnerDatabaseAccount = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT;
portalRunnerDatabaseAccountKey = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY;
}
const notebooksTestRunnerTenantId = process.env.NOTEBOOKS_TEST_RUNNER_TENANT_ID; const notebooksTestRunnerTenantId = process.env.NOTEBOOKS_TEST_RUNNER_TENANT_ID;
const notebooksTestRunnerClientId = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_ID; const notebooksTestRunnerClientId = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_ID;
const notebooksTestRunnerClientSecret = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET; const notebooksTestRunnerClientSecret = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET;
const portalRunnerDatabaseAccount = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT;
const portalRunnerDatabaseAccountKey = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY;
const portalRunnerSubscripton = process.env.PORTAL_RUNNER_SUBSCRIPTION; const portalRunnerSubscripton = process.env.PORTAL_RUNNER_SUBSCRIPTION;
const portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP; const portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP;

View File

@ -42,6 +42,13 @@
</staticContent> </staticContent>
</system.webServer> </system.webServer>
</location> </location>
<location path="config.json">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
<location path="mpac/explorer.html"> <location path="mpac/explorer.html">
<system.webServer> <system.webServer>
<staticContent> <staticContent>
@ -56,4 +63,11 @@
</staticContent> </staticContent>
</system.webServer> </system.webServer>
</location> </location>
<location path="mpac/config.json">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
</configuration> </configuration>