Merge branch 'master' into users/fnbalaji/PortalChangesForDGW

This commit is contained in:
fnbalaji 2021-02-15 23:39:50 -08:00 committed by GitHub
commit f922103e5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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/DataSamplesUtil.test.ts
src/Explorer/DataSamples/DataSamplesUtil.ts
src/Explorer/Explorer.ts
src/Explorer/Explorer.tsx
src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.test.ts
src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts
src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts

View File

@ -166,6 +166,8 @@ jobs:
PORTAL_RUNNER_RESOURCE_GROUP: ${{ secrets.PORTAL_RUNNER_RESOURCE_GROUP }}
PORTAL_RUNNER_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT }}
PORTAL_RUNNER_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY }}
PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT }}
PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY }}
NOTEBOOKS_TEST_RUNNER_TENANT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_TENANT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }}

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;
@GalleryBackgroundColor: #fdfdfd;
//Icons
@InfoIconColor: #0072c6;
@WarningIconColor: #db7500;
@ErrorIconColor: #b91f26;
/******************************************************************************
METRICS
/******************************************************************************/

View File

@ -1523,6 +1523,21 @@ p {
.tooltipVisible();
}
.inputTooltip {
.inputTooltip();
}
.inputTooltip .inputTooltipText {
top: -68px;
.inputTooltipText();
}
.inputTooltip .inputTooltipText::after {
border-width: @MediumSpace @MediumSpace 0 @MediumSpace;
top: 55px;
.inputTooltipTextAfter();
}
.infoTooltip a {
color: @AccentHigh;
}
@ -3028,3 +3043,45 @@ settings-pane {
.collapsibleSection :hover {
cursor: pointer;
}
.messageBarInfoIcon {
color: @InfoIconColor;
}
.messageBarWarningIcon {
color: @WarningIconColor;
}
.freeTierInfoBanner {
background-color: @BaseLow;
display: inline-flex;
padding: @DefaultSpace;
width: 100%;
.freeTierInfoIcon img {
height: 28px;
width: 28px;
margin-left: 4px;
}
.freeTierInfoMessage {
margin: auto 0;
padding-left: @MediumSpace;
}
}
.freeTierInlineWarning {
display: inline-flex;
padding: 8px 8px 8px 0;
width: 100%;
.freeTierWarningIcon img {
height: 20px;
width: 20px;
}
.freeTierWarningMessage {
margin: auto 0;
padding-left: @SmallSpace;
}
}

View File

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

View File

@ -76,7 +76,7 @@ export const getCollectionUsageSizeInKB = async (databaseName: string, container
return dataUsageSizeInKb + indexUsageSizeInKb;
} catch (error) {
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.
hostedExplorerURL: string;
armAPIVersion?: string;
ENABLE_GALLERY_PUBLISH?: boolean;
}
// Default configuration
@ -79,7 +80,11 @@ if (process.env.NODE_ENV === "development") {
export async function initializeConfiguration(): Promise<ConfigContext> {
try {
const response = await fetch("./config.json");
const response = await fetch("./config.json", {
headers: {
"If-None-Match": "", // disable client side cache
},
});
if (response.status === 200) {
try {
const { allowedParentFrameOrigins, ...externalConfig } = await response.json();

View File

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

View File

@ -45,7 +45,8 @@ describe("Component Registerer", () => {
});
it("should register settings-tab-v2 component", () => {
expect(ko.components.isRegistered("settings-tab-v2")).toBe(true);
expect(ko.components.isRegistered("database-settings-tab-v2")).toBe(true);
expect(ko.components.isRegistered("collection-settings-tab-v2")).toBe(true);
});
it("should register query-tab component", () => {

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,14 +7,20 @@ import {
} from "./GalleryAndNotebookViewerComponent";
export class GalleryAndNotebookViewerComponentAdapter implements ReactAdapter {
private key: string;
public parameters: ko.Observable<number>;
constructor(private props: GalleryAndNotebookViewerComponentProps) {
this.reset();
this.parameters = ko.observable<number>(Date.now());
}
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 {

View File

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

View File

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

View File

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

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>
</StackItem>
<StackItem>
<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>
</StackItem>
<StackItem>
<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
href="https://aka.ms/cosmos-code-of-conduct"
target="_blank"
>
code of conduct
</StyledLinkBase>
and
<StyledLinkBase
href="https://aka.ms/ms-privacy-policy"
target="_blank"
>
privacy statement
code of conduct.
</StyledLinkBase>
</Text>
</StackItem>
<StackItem>
<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]}
styles={
Object {

View File

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

View File

@ -31,6 +31,26 @@ export interface 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 {
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
@ -49,19 +69,7 @@ export class NotebookMetadataComponent extends React.Component<NotebookMetadataC
</Text>
</Stack.Item>
<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>
<Stack.Item>{this.renderFavouriteButton()}</Stack.Item>
{this.props.downloadButtonText && (
<Stack.Item>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -142,6 +142,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
id="autopilotInput"
key="auto pilot throughput input"
label="Max RU/s"
min={4000}
onChange={[Function]}
required={true}
step={1000}
@ -260,6 +261,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
disabled={false}
id="throughputInput"
key="provisioned throughput input"
min={10000}
onChange={[Function]}
required={true}
step={100}
@ -533,6 +535,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
disabled={false}
id="throughputInput"
key="provisioned throughput input"
min={10000}
onChange={[Function]}
required={true}
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.
<br />
Database:
test
, Container:
test
Database: test, Container: test
, Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s
</Text>
</StyledMessageBarBase>

View File

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

View File

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

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.
<br />
Database:
sampleDb
, Container:
sampleCollection
Database: sampleDb, Container: sampleCollection
, Current manual throughput: 1000 RU/s
</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.
<br />
Database:
sampleDb
, Container:
sampleCollection
Database: sampleDb, Container: sampleCollection
, Current manual throughput: 1000 RU/s, Target manual throughput: 2000
</Text>
<Text

View File

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

View File

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

View File

@ -818,7 +818,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
let indexingPolicy: DataModels.IndexingPolicy;
let createMongoWildcardIndex: boolean;
// todo - remove mongo indexing policy ticket # 616274
if (this.container.isPreferredApiMongoDB()) {
if (this.container.isPreferredApiMongoDB() && this.container.isEnableMongoCapabilityPresent()) {
createMongoWildcardIndex = this.shouldCreateMongoWildcardIndex();
} else if (this.showIndexingOptionsForSharedThroughput()) {
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 Q from "q";
import * as ViewModels from "../../Contracts/ViewModels";
import * as Constants from "../../Common/Constants";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { CassandraAPIDataClient } from "../Tables/TableDataClient";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { ContextualPaneBase } from "./ContextualPaneBase";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
@ -50,18 +48,7 @@ export default class DeleteCollectionConfirmationPane extends ContextualPaneBase
dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(),
});
let promise: Promise<any>;
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(
return deleteCollection(selectedCollection.databaseId, selectedCollection.id()).then(
() => {
this.isExecuting(false);
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,81 +52,71 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase {
dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(),
});
// TODO: Should not be a Q promise anymore, but the Cassandra code requires it
let promise: Q.Promise<any>;
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.close();
this.container.refreshAllDatabases();
this.container.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id());
this.container.selectedNode(null);
selectedDatabase
.collections()
.forEach((collection: ViewModels.Collection) =>
this.container.tabsManager.closeTabsByComparator(
(tab) =>
tab.node?.id() === collection.id() &&
(tab.node as ViewModels.Collection).databaseId === collection.databaseId
)
);
this.resetData();
TelemetryProcessor.traceSuccess(
Action.DeleteDatabase,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
databaseId: selectedDatabase.id(),
dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(),
},
startKey
);
if (this.shouldRecordFeedback()) {
let deleteFeedback = new DeleteFeedback(
this.container.databaseAccount().id,
this.container.databaseAccount().name,
DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience()),
this.databaseDeleteFeedback()
return Q(
deleteDatabase(selectedDatabase.id()).then(
() => {
this.isExecuting(false);
this.close();
this.container.refreshAllDatabases();
this.container.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id());
this.container.selectedNode(null);
selectedDatabase
.collections()
.forEach((collection: ViewModels.Collection) =>
this.container.tabsManager.closeTabsByComparator(
(tab) =>
tab.node?.id() === collection.id() &&
(tab.node as ViewModels.Collection).databaseId === collection.databaseId
)
);
this.resetData();
TelemetryProcessor.traceSuccess(
Action.DeleteDatabase,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
databaseId: selectedDatabase.id(),
dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(),
},
startKey
);
TelemetryProcessor.trace(Action.DeleteDatabase, ActionModifiers.Mark, {
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)),
});
if (this.shouldRecordFeedback()) {
let deleteFeedback = new DeleteFeedback(
this.container.databaseAccount().id,
this.container.databaseAccount().name,
DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience()),
this.databaseDeleteFeedback()
);
this.databaseDeleteFeedback("");
TelemetryProcessor.trace(Action.DeleteDatabase, ActionModifiers.Mark, {
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)),
});
this.databaseDeleteFeedback("");
}
},
(error: any) => {
this.isExecuting(false);
const errorMessage = getErrorMessage(error);
this.formErrors(errorMessage);
this.formErrorsDetails(errorMessage);
TelemetryProcessor.traceFailure(
Action.DeleteDatabase,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
databaseId: selectedDatabase.id(),
dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(),
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey
);
}
},
(error: any) => {
this.isExecuting(false);
const errorMessage = getErrorMessage(error);
this.formErrors(errorMessage);
this.formErrorsDetails(errorMessage);
TelemetryProcessor.traceFailure(
Action.DeleteDatabase,
{
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
databaseId: selectedDatabase.id(),
dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(),
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey
);
}
)
);
}

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 { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
import { HttpStatusCodes } from "../../Common/Constants";
import { handleError, getErrorMessage } from "../../Common/ErrorHandlingUtils";
import { handleError, getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import { GalleryTab } from "../Controls/NotebookGallery/GalleryViewerComponent";
import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { FileSystemUtil } from "../Notebook/FileSystemUtil";
export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>;
@ -66,7 +70,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection,
onError: this.createFormError,
clearFormError: this.clearFormError,
};
@ -140,10 +144,21 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.isExecuting = true;
this.triggerRender();
let startKey: number;
if (!this.name || !this.description || !this.author || !this.imageSrc) {
const formError = `Failed to publish ${this.name} to gallery`;
const formErrorDetail = "Name, description, author and cover image are required";
this.createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit");
this.isExecuting = false;
return;
}
try {
if (!this.name || !this.description || !this.author) {
throw new Error("Name, description, and author are required");
}
startKey = traceStart(Action.NotebooksGalleryPublish, {
databaseAccountName: this.container.databaseAccount()?.name,
defaultExperience: this.container.defaultExperience(),
});
const response = await this.junoClient.publishNotebook(
this.name,
@ -157,17 +172,43 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
const data = response.data;
if (data) {
let isPublishPending = false;
if (data.pendingScanJobIds?.length > 0) {
isPublishPending = true;
NotificationConsoleUtils.logConsoleInfo(
`Content of ${this.name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).`
);
} else {
NotificationConsoleUtils.logConsoleInfo(`Published ${this.name} to gallery`);
this.container.openGallery(GalleryTab.Published);
}
traceSuccess(
Action.NotebooksGalleryPublish,
{
databaseAccountName: this.container.databaseAccount()?.name,
defaultExperience: this.container.defaultExperience(),
notebookId: data.id,
isPublishPending,
},
startKey
);
}
} catch (error) {
traceFailure(
Action.NotebooksGalleryPublish,
{
databaseAccountName: this.container.databaseAccount()?.name,
defaultExperience: this.container.defaultExperience(),
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
const errorMessage = getErrorMessage(error);
this.formError = `Failed to publish ${this.name} to gallery`;
this.formError = `Failed to publish ${FileSystemUtil.stripExtension(this.name, "ipynb")} to gallery`;
this.formErrorDetail = `${errorMessage}`;
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", this.formError);
return;
@ -180,7 +221,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.close();
}
private createFormErrorForLargeImageSelection = (formError: string, formErrorDetail: string, area: string): void => {
private createFormError = (formError: string, formErrorDetail: string, area: string): void => {
this.formError = formError;
this.formErrorDetail = formErrorDetail;
handleError(formErrorDetail, area, formError);

View File

@ -14,7 +14,7 @@ export interface PublishNotebookPaneProps {
notebookAuthor: string;
notebookCreatedDate: string;
notebookObject: ImmutableNotebook;
notebookParentDomElement: HTMLElement;
notebookParentDomElement?: HTMLElement;
onChangeName: (newValue: string) => void;
onChangeDescription: (newValue: string) => void;
onChangeTags: (newValue: string) => void;
@ -54,7 +54,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
super(props);
this.state = {
type: ImageTypes.Url,
type: ImageTypes.CustomImage,
notebookName: props.notebookName,
notebookDescription: "",
notebookTags: "",
@ -110,7 +110,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
};
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.props.notebookName,
@ -120,6 +120,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
this.thumbnailUrlProps = {
label: "Cover image url",
ariaLabel: "Cover image url",
required: true,
onChange: (event, newValue) => {
this.props.onChangeImageSrc(newValue);
this.setState({ imageSrc: newValue });
@ -140,17 +141,23 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
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 = {
label: "Cover image",
defaultSelectedKey: ImageTypes.Url,
defaultSelectedKey: ImageTypes.CustomImage,
ariaLabel: "Cover image",
options: [
ImageTypes.Url,
ImageTypes.CustomImage,
ImageTypes.TakeScreenshot,
ImageTypes.UseFirstDisplayOutput,
].map((value: string) => ({ text: value, key: value })),
options: options.map((value: string) => ({ text: value, key: value })),
onChange: async (event, options) => {
this.setState({ imageSrc: undefined });
this.props.onChangeImageSrc(undefined);
this.props.clearFormError();
if (options.text === ImageTypes.TakeScreenshot) {
try {
@ -172,11 +179,12 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
this.nameProps = {
label: "Name",
ariaLabel: "Name",
defaultValue: this.props.notebookName,
defaultValue: FileSystemUtil.stripExtension(this.props.notebookName, "ipynb"),
required: true,
onChange: (event, newValue) => {
this.props.onChangeName(newValue);
this.setState({ notebookName: newValue });
const notebookName = newValue + ".ipynb";
this.props.onChangeName(notebookName);
this.setState({ notebookName });
},
};
@ -293,16 +301,16 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
thumbnailUrl: this.state.imageSrc,
created: this.props.notebookCreatedDate,
isSample: false,
downloads: 0,
favorites: 0,
views: 0,
downloads: undefined,
favorites: undefined,
views: undefined,
newCellId: undefined,
policyViolations: undefined,
pendingScanJobIds: undefined,
}}
isFavorite={false}
showDownload={true}
showDelete={true}
isFavorite={undefined}
showDownload={false}
showDelete={false}
onClick={undefined}
onTagClick={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>
<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>
</StackItem>
<StackItem>
@ -25,7 +25,7 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
<StackItem>
<StyledTextFieldBase
ariaLabel="Name"
defaultValue="SampleNotebook.ipynb"
defaultValue="SampleNotebook"
label="Name"
onChange={[Function]}
required={true}
@ -52,36 +52,29 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
<StackItem>
<StyledWithResponsiveMode
ariaLabel="Cover image"
defaultSelectedKey="URL"
defaultSelectedKey="Custom Image"
label="Cover image"
onChange={[Function]}
options={
Array [
Object {
"key": "URL",
"text": "URL",
},
Object {
"key": "Custom Image",
"text": "Custom Image",
},
Object {
"key": "Take Screenshot",
"text": "Take Screenshot",
},
Object {
"key": "Use First Display Output",
"text": "Use First Display Output",
"key": "URL",
"text": "URL",
},
]
}
/>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Cover image url"
label="Cover image url"
<input
accept="image/*"
id="selectImageFile"
onChange={[Function]}
type="file"
/>
</StackItem>
<StackItem>
@ -96,8 +89,8 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
"author": "CosmosDB",
"created": "2020-07-17T00:00:00Z",
"description": "",
"downloads": 0,
"favorites": 0,
"downloads": undefined,
"favorites": undefined,
"gitSha": undefined,
"id": undefined,
"isSample": false,
@ -109,12 +102,11 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
"",
],
"thumbnailUrl": undefined,
"views": 0,
"views": undefined,
}
}
isFavorite={false}
showDelete={true}
showDownload={true}
showDelete={false}
showDownload={false}
/>
</StackItem>
</Stack>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ import * as DataModels from "../../Contracts/DataModels";
import TabsBase from "./TabsBase";
import { SettingsComponentAdapter } from "../Controls/Settings/SettingsComponentAdapter";
import { SettingsComponentProps } from "../Controls/Settings/SettingsComponent";
import Explorer from "../Explorer";
import { traceFailure } from "../../Shared/Telemetry/TelemetryProcessor";
import ko from "knockout";
import * as Constants from "../../Common/Constants";
@ -11,23 +10,27 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
export default class SettingsTabV2 extends TabsBase {
export class SettingsTabV2 extends TabsBase {
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);
this.options = options;
this.tabId = "SettingsV2-" + this.tabId;
const props: SettingsComponentProps = {
settingsTab: this,
};
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.offerRead = ko.observable(false);
this.settingsComponentAdapter.parameters = ko.computed<boolean>(() => {
@ -45,49 +48,95 @@ export default class SettingsTabV2 extends TabsBase {
public async onActivate(): Promise<void> {
try {
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(
(data: DataModels.Notification) => {
this.notification = data;
this.notificationRead(true);
const collection: ViewModels.Collection = this.collection as ViewModels.Collection;
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.notificationRead(true);
} catch (error) {
const errorMessage = getErrorMessage(error);
this.notification = undefined;
this.notificationRead(true);
traceFailure(
Action.Tab,
{
databaseAccountName: this.collection.container.databaseAccount().name,
databaseName: this.collection.databaseId,
collectionName: this.collection.id(),
defaultExperience: this.collection.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle,
error: errorMessage,
errorStack: getErrorStack(error),
},
(error) => {
const errorMessage = getErrorMessage(error);
this.notification = undefined;
this.notificationRead(true);
traceFailure(
Action.Tab,
{
databaseAccountName: this.options.collection.container.databaseAccount().name,
databaseName: this.options.collection.databaseId,
collectionName: this.options.collection.id(),
defaultExperience: this.options.collection.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.tabTitle,
error: errorMessage,
errorStack: getErrorStack(error),
},
this.options.onLoadStartKey
);
logConsoleError(
`Error while fetching container settings for container ${this.options.collection.id()}: ${errorMessage}`
);
throw error;
}
this.onLoadStartKey
);
logConsoleError(`Error while fetching container settings for container ${this.collection.id()}: ${errorMessage}`);
throw error;
} finally {
this.offerRead(true);
this.isExecuting(false);
}
super.onActivate();
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.SettingsV2);
}
public getSettingsTabContainer(): Explorer {
return this.getContainer();
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.CollectionSettingsV2);
}
}
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);
}
protected getContainer() {
public getContainer() {
return this._container;
}
}

View File

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

View File

@ -143,7 +143,11 @@
<!-- /ko -->
<!-- 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 -->
</div>
<!-- /ko -->

View File

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

View File

@ -26,7 +26,7 @@ import MongoQueryTab from "../Tabs/MongoQueryTab";
import MongoShellTab from "../Tabs/MongoShellTab";
import QueryTab from "../Tabs/QueryTab";
import QueryTablesTab from "../Tabs/QueryTablesTab";
import SettingsTabV2 from "../Tabs/SettingsTabV2";
import { CollectionSettingsTabV2 } from "../Tabs/SettingsTabV2";
import ConflictId from "./ConflictId";
import DocumentId from "./DocumentId";
import StoredProcedure from "./StoredProcedure";
@ -544,10 +544,12 @@ export default class Collection implements ViewModels.Collection {
});
const tabTitle = !this.offer() ? "Settings" : "Scale & Settings";
const pendingNotificationsPromise: Promise<DataModels.Notification> = this._getPendingThroughputSplitNotification();
const matchingTabs = this.container.tabsManager.getTabs(ViewModels.CollectionTabKind.SettingsV2, (tab) => {
return tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id();
});
const matchingTabs = this.container.tabsManager.getTabs(
ViewModels.CollectionTabKind.CollectionSettingsV2,
(tab) => {
return tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id();
}
);
const traceStartData = {
databaseAccountName: this.container.databaseAccount().name,
@ -569,26 +571,20 @@ export default class Collection implements ViewModels.Collection {
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
};
let settingsTabV2 = matchingTabs && (matchingTabs[0] as SettingsTabV2);
this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions, pendingNotificationsPromise);
let settingsTabV2 = matchingTabs && (matchingTabs[0] as CollectionSettingsTabV2);
this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions);
};
private launchSettingsTabV2 = (
settingsTabV2: SettingsTabV2,
settingsTabV2: CollectionSettingsTabV2,
traceStartData: any,
settingsTabOptions: ViewModels.TabOptions,
getPendingNotification: Promise<DataModels.Notification>
settingsTabOptions: ViewModels.TabOptions
): void => {
const settingsTabV2Options: ViewModels.SettingsTabV2Options = {
...settingsTabOptions,
getPendingNotification: getPendingNotification,
};
if (!settingsTabV2) {
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, traceStartData);
settingsTabV2Options.onLoadStartKey = startKey;
settingsTabV2Options.tabKind = ViewModels.CollectionTabKind.SettingsV2;
settingsTabV2 = new SettingsTabV2(settingsTabV2Options);
settingsTabOptions.onLoadStartKey = startKey;
settingsTabOptions.tabKind = ViewModels.CollectionTabKind.CollectionSettingsV2;
settingsTabV2 = new CollectionSettingsTabV2(settingsTabOptions);
this.container.tabsManager.activateNewTab(settingsTabV2);
} else {
this.container.tabsManager.activateTab(settingsTabV2);
@ -973,10 +969,6 @@ export default class Collection implements ViewModels.Collection {
this.uploadFiles(event.originalEvent.dataTransfer.files);
}
public onDeleteCollectionContextMenuClick(source: ViewModels.Collection, event: MouseEvent | KeyboardEvent) {
this.container.deleteCollectionConfirmationPane.open();
}
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
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> {
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 {
const uploadDetailsRecords: UploadDetailsRecord[] = uploadDetails.data;
const numFiles: number = uploadDetailsRecords.length;

View File

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

View File

@ -15,12 +15,13 @@ import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
import RefreshIcon from "../../../images/refresh-cosmos.svg";
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
import FileIcon from "../../../images/notebook/file-cosmos.svg";
import PublishIcon from "../../../images/notebook/publish_content.svg";
import { ArrayHashMap } from "../../Common/ArrayHashMap";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import _ from "underscore";
import { IPinnedRepo } from "../../Juno/JunoClient";
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 * as GitHubUtils from "../../Utils/GitHubUtils";
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
if (!this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
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 "./GalleryViewer.less";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { Text, Link } from "office-ui-fabric-react";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { initializeConfiguration } from "../ConfigContext";
import { configContext, initializeConfiguration } from "../ConfigContext";
import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent";
import {
GalleryAndNotebookViewerComponent,
@ -24,6 +25,7 @@ const onInit = async () => {
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
const props: GalleryAndNotebookViewerComponentProps = {
isGalleryPublishEnabled: configContext.ENABLE_GALLERY_PUBLISH,
junoClient: new JunoClient(),
selectedTab: galleryViewerProps.selectedTab || GalleryTab.OfficialSamples,
sortBy: galleryViewerProps.sortBy || SortBy.MostViewed,
@ -31,7 +33,7 @@ const onInit = async () => {
};
const element = (
<>
<div className="standalone-gallery-root">
<header>
<GalleryHeaderComponent />
</header>
@ -52,7 +54,7 @@ const onInit = async () => {
<GalleryAndNotebookViewerComponent {...props} />
</div>
</>
</div>
);
ReactDOM.render(element, document.getElementById("galleryContent"));

View File

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

View File

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

View File

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

View File

@ -92,6 +92,28 @@ export enum Action {
SettingsV2Updated,
SettingsV2Discarded,
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 = {
@ -134,3 +156,8 @@ export enum SourceBlade {
ScriptExplorer,
Keys,
}
export enum Source {
ResourceTreeMenu = "ResourceTreeMenu",
CommandBarMenu = "CommandBarMenu",
}

View File

@ -12,7 +12,7 @@ import { getDataExplorerWindow } from "../../Utils/WindowUtils";
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({
type: MessageTypes.TelemetryInfo,
data: {

View File

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

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>
<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>
</div>
</nav>

View File

@ -82,12 +82,12 @@ describe("Collection Add and Delete Cassandra spec", () => {
await frame.click('span[class="treeComponentMenuItemLabel deleteCollectionMenuItemLabel"]');
// confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true });
await frame.type('input[data-test="confirmCollectionId"]', textId);
await frame.waitFor('input[id="confirmCollectionId"]', { visible: true });
await frame.type('input[id="confirmCollectionId"]', textId);
// click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true });
await frame.click('input[data-test="deleteCollection"]');
await frame.waitFor('button[id="sidePanelOkButton"]', { visible: true });
await frame.click('button[id="sidePanelOkButton"]');
await frame.waitFor(LOADING_STATE_DELAY);
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"]');
// confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true });
await frame.type('input[data-test="confirmCollectionId"]', textId);
await frame.waitFor('input[id="confirmCollectionId"]', { visible: true });
await frame.type('input[id="confirmCollectionId"]', textId);
// click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true });
await frame.click('input[data-test="deleteCollection"]');
await frame.waitFor('button[id="sidePanelOkButton"]', { visible: true });
await frame.click('button[id="sidePanelOkButton"]');
await frame.waitFor(LOADING_STATE_DELAY);
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 { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils";
import { SelfServeType } from "../../src/SelfServe/SelfServeUtils";
import { ApiKind } from "../../src/Contracts/DataModels";
jest.setTimeout(300000);
@ -10,9 +11,13 @@ describe("Self Serve", () => {
it("Launch Self Serve Example", async () => {
try {
frame = await getTestExplorerFrame(
ApiKind.SQL,
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}
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"]');
// confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true });
await frame.type('input[data-test="confirmCollectionId"]', textId);
await frame.waitFor('input[id="confirmCollectionId"]', { visible: true });
await frame.type('input[id="confirmCollectionId"]', textId);
// click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true });
await frame.click('input[data-test="deleteCollection"]');
await frame.waitFor('button[id="sidePanelOkButton"]', { visible: true });
await frame.click('button[id="sidePanelOkButton"]');
await frame.waitFor(LOADING_STATE_DELAY);
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"]');
// confirm delete container
await frame.waitFor('input[data-test="confirmCollectionId"]', { visible: true });
await frame.type('input[data-test="confirmCollectionId"]', textId);
await frame.waitFor('input[id="confirmCollectionId"]', { visible: true });
await frame.type('input[id="confirmCollectionId"]', textId);
// click delete
await frame.waitFor('input[data-test="deleteCollection"]', { visible: true });
await frame.click('input[data-test="deleteCollection"]');
await frame.waitFor('button[id="sidePanelOkButton"]', { visible: true });
await frame.click('button[id="sidePanelOkButton"]');
await frame.waitFor(LOADING_STATE_DELAY);
await frame.waitForSelector('div[class="splashScreen"] > div[class="title"]', { visible: true });
await frame.waitFor(LOADING_STATE_DELAY);

View File

@ -1,9 +1,9 @@
import "../../less/hostedexplorer.less";
import { TestExplorerParams } from "./TestExplorerParams";
import { DatabaseAccountsGetResponse } from "@azure/arm-cosmosdb/esm/models";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import * as msRest from "@azure/ms-rest-js";
import * as ViewModels from "../../src/Contracts/ViewModels";
import { Capability, DatabaseAccount } from "../../src/Contracts/DataModels";
class CustomSigner implements msRest.ServiceClientCredentials {
private token: string;
@ -22,9 +22,32 @@ const getDatabaseAccount = async (
notebooksAccountSubscriptonId: string,
notebooksAccountResourceGroup: string,
notebooksAccountName: string
): Promise<DatabaseAccountsGetResponse> => {
): Promise<DatabaseAccount> => {
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> => {
@ -55,7 +78,7 @@ const initTestExplorer = async (): Promise<void> => {
subscriptionId: portalRunnerSubscripton,
resourceGroup: portalRunnerResourceGroup,
authorizationToken: `Bearer ${token}`,
features: {},
features: { sampleFeature: "sampleFeatureValue" },
hasWriteAccess: true,
csmEndpoint: "https://management.azure.com",
dnsSuffix: "documents.azure.com",

View File

@ -1,18 +1,30 @@
import { Frame } from "puppeteer";
import { TestExplorerParams } from "./TestExplorerParams";
import { ClientSecretCredential } from "@azure/identity";
import { ApiKind } from "../../src/Contracts/DataModels";
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) {
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 notebooksTestRunnerClientId = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_ID;
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 portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP;

View File

@ -34,26 +34,40 @@
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
</location>
<location path="hostedExplorer.html">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
</location>
<location path="config.json">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
<location path="mpac/explorer.html">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
</location>
<location path="mpac/hostedExplorer.html">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
</location>
<location path="mpac/config.json">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
</configuration>