Compare commits

...

35 Commits

Author SHA1 Message Date
Balaji Sridharan
5b0a98dce7 Removing TODO comments 2021-02-17 10:43:23 -08:00
Balaji Sridharan
67ebce444f Stylecop changes 2021-02-16 00:21:44 -08:00
Balaji Sridharan
a09bcc7197 Merge branch 'users/fnbalaji/PortalChangesForDGW' of https://github.com/Azure/cosmos-explorer into users/fnbalaji/PortalChangesForDGW 2021-02-15 23:49:54 -08:00
Balaji Sridharan
b2390e23e7 Portal changes for DedicatedGateway. CR feedback 2021-02-15 23:49:14 -08:00
fnbalaji
f922103e5c Merge branch 'master' into users/fnbalaji/PortalChangesForDGW 2021-02-15 23:39:50 -08:00
victor-meng
22d8a7a1be Move database settings tab to react (#386)
Co-authored-by: Steve Faulkner <southpolesteve@gmail.com>
2021-02-10 16:06:14 -06:00
victor-meng
4210e0752b Move delete collection confirmation pane to react (#417) 2021-02-10 13:44:00 -08:00
Steve Faulkner
b217d4be1b Delete Cassandra tables/keyspaces via ARM (#436) 2021-02-08 18:52:53 -06:00
victor-meng
81fd442fad Make getCollectionDataUsageSize call fail gracefully (#434) 2021-02-08 16:02:02 -08:00
Steve Faulkner
87f7dd2230 Rename Feedback -> Report Issue (#425)
Co-authored-by: victor-meng <56978073+victor-meng@users.noreply.github.com>
2021-02-08 14:23:55 -06:00
Srinath Narayanan
9926fd97a2 Test explorer changes (#420)
* Changes to publish pane

* fixed format errors

* fixed failing test

* added test explorer changes for mongo accounts

* added log for test

* fixed lit errors

* added secrets to ci.yml file

* fixed failing self serve test
2021-02-08 09:42:16 -08:00
Balaji Sridharan
faa98de9e9 Portal changes for DedicatedGateway
Changes to support creation and deletion of DedicatedGateway resource.

Tested locally with various scenarios.
2021-02-08 05:16:10 -08:00
Tanuj Mittal
2a7546e0de Skip SelfServe e2e test (#432) 2021-02-06 02:30:46 +05:30
Tanuj Mittal
4b442dd869 Use GET instead of PATCH for some Juno endpoints (#431) 2021-02-06 02:01:34 +05:30
Tanuj Mittal
f0b4737313 Update gallery colors (#430)
* Update gallery colors

* fix lint error

* Fix test
2021-02-06 01:19:36 +05:30
Srinath Narayanan
8dc5ed590a Added Spinner for public gallery (#427)
* Added more publish changes

* addressed PR comments

* fixed lint errors

Co-authored-by: Tanuj Mittal <tamitta@microsoft.com>
2021-02-05 11:32:26 -08:00
Tanuj Mittal
afaa844d28 Telemetry updates (#429) 2021-02-06 00:26:20 +05:30
Tanuj Mittal
3e5a876ef2 Fix setting isGalleryPublishEnabled when flight is enabled (#428) 2021-02-05 20:55:08 +05:30
Tanuj Mittal
51abf1560a Disable dark overlay for Dialog (#423) 2021-02-05 19:28:57 +05:30
Srinath Narayanan
1c0fed88c0 Changes to cards in notebook gallery (#422)
* added gallery changes

* addressed PR comments
2021-02-05 02:32:55 -08:00
Tanuj Mittal
93cfd52e36 Update Code of Conduct Overlay and other minor changes (#424)
* Use light theme for coc-overlay

* Updates

* Fix vertical height for COC overlay
2021-02-05 14:56:50 +05:30
Tanuj Mittal
3fd014ddad Disable caching for config.json file (#421)
* Disable caching for config.json file

* Disable cache when fetching config.json
2021-02-04 15:25:13 +05:30
Srinath Narayanan
3b6fda4fa5 Changes to notebook publish pane (#419)
* Changes to publish pane

* fixed format errors

* fixed failing test

Co-authored-by: Tanuj Mittal <tamitta@microsoft.com>
2021-02-03 10:46:51 -08:00
Tanuj Mittal
db7c45c9b8 Enable gallery publishing in MPAC (#416)
* Enable gallery publishing in MPAC

* Address feedback

* Use ENABLE_GALLERY_PUBLISH config in standalone gallery

* Fix test
2021-02-03 23:42:11 +05:30
Tanuj Mittal
4f6b75fe79 More gallery updates (#418)
* More gallery updates

* Add PublishContent icon

* Address feedback
2021-02-03 23:24:27 +05:30
Tanuj Mittal
5038a01079 Add telemetry for Notebooks Gallery and other updates (#413)
* Add telemetry for Notebooks Gallery

* More changes

* Address feedback and fix lint error

* Fix margins for My published work
2021-02-03 14:48:50 +05:30
Tanuj Mittal
e0063c76d9 Add support for gallerypublish flight (#412) 2021-02-03 02:05:19 +05:30
Tanuj Mittal
9278654479 Public gallery improvements (#409)
- [x] Don't show extension in name field for publish
- [x] Open "Your published work" tab after publishing
- [x] Continue showing dialog for Report Abuse status
- [x] For showing COC in Public Gallery tab show backdrop of thumbnails
- [x] Liked -> My Favorites & Your published work -> My published work
2021-01-29 17:04:38 +00:00
Tanuj Mittal
59113d7bbf Update Juno endpoints (#405) 2021-01-29 20:28:20 +05:30
Tim Sander
88d8200c14 Check that customer is using Mongo 3.6 before applying index everything policy (#410) 2021-01-28 15:26:47 -06:00
Srinath Narayanan
6aaddd9c60 Added localization for the Self Serve Model (#406)
* added localization for selfserve model

* added comment

* addressed PR comments

* fixed format errors

* Addressed PR comments
2021-01-28 11:17:02 -08:00
Jordi Bunster
f8ede0cc1e Remove Q from ViewModels (#390)
I got cold feet at the thought of merging #324 in one go, so I'm going to split it into smaller chunks and keep rebasing the large one until there's no more Q.
2021-01-28 18:13:26 +00:00
Laurent Nguyen
bddb288a89 Update package versions and package-lock.json (#404)
The file `package-lock.json` is not in sync with `package.json` anymore. This causes build issues when upgrading a package.
This change sync's `package-lock.json` and fixes the build issues.
2021-01-28 08:50:24 +00:00
Steve Faulkner
a14d20a88e Fix applyExplorerBindings call in Portal (#408) 2021-01-27 20:37:14 -06:00
Steve Faulkner
f1db1ed978 Region Select Button (#407) 2021-01-27 15:32:53 -06:00
112 changed files with 7694 additions and 4315 deletions

View File

@@ -87,7 +87,7 @@ src/Explorer/DataSamples/ContainerSampleGenerator.test.ts
src/Explorer/DataSamples/ContainerSampleGenerator.ts src/Explorer/DataSamples/ContainerSampleGenerator.ts
src/Explorer/DataSamples/DataSamplesUtil.test.ts src/Explorer/DataSamples/DataSamplesUtil.test.ts
src/Explorer/DataSamples/DataSamplesUtil.ts src/Explorer/DataSamples/DataSamplesUtil.ts
src/Explorer/Explorer.ts src/Explorer/Explorer.tsx
src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.test.ts src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.test.ts
src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts
src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts

View File

@@ -156,6 +156,7 @@ jobs:
run: | run: |
npm ci npm ci
npm start & npm start &
node utils/cleanupDBs.js
npm run wait-for-server npm run wait-for-server
npm run test:e2e npm run test:e2e
shell: bash shell: bash
@@ -165,6 +166,8 @@ jobs:
PORTAL_RUNNER_RESOURCE_GROUP: ${{ secrets.PORTAL_RUNNER_RESOURCE_GROUP }} PORTAL_RUNNER_RESOURCE_GROUP: ${{ secrets.PORTAL_RUNNER_RESOURCE_GROUP }}
PORTAL_RUNNER_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT }} PORTAL_RUNNER_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT }}
PORTAL_RUNNER_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY }} PORTAL_RUNNER_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY }}
PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT }}
PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY }}
NOTEBOOKS_TEST_RUNNER_TENANT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_TENANT_ID }} NOTEBOOKS_TEST_RUNNER_TENANT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_TENANT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }} NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }}
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }} NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }}

View File

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

View File

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

After

Width:  |  Height:  |  Size: 329 B

View File

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

View File

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

4231
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,10 @@
"@azure/identity": "1.2.1", "@azure/identity": "1.2.1",
"@babel/plugin-proposal-class-properties": "7.12.1", "@babel/plugin-proposal-class-properties": "7.12.1",
"@babel/plugin-proposal-decorators": "7.12.12", "@babel/plugin-proposal-decorators": "7.12.12",
"@jupyterlab/services": "6.0.0-rc.2", "@jupyterlab/services": "6.0.2",
"@jupyterlab/terminal": "3.0.0-rc.2", "@jupyterlab/terminal": "3.0.3",
"@microsoft/applicationinsights-web": "2.5.9", "@microsoft/applicationinsights-web": "2.5.9",
"@nteract/commutable": "7.3.2", "@nteract/commutable": "7.4.2",
"@nteract/connected-components": "6.8.2", "@nteract/connected-components": "6.8.2",
"@nteract/core": "15.1.0", "@nteract/core": "15.1.0",
"@nteract/data-explorer": "8.0.3", "@nteract/data-explorer": "8.0.3",
@@ -64,6 +64,9 @@
"eslint-plugin-react": "7.20.0", "eslint-plugin-react": "7.20.0",
"hasher": "1.2.0", "hasher": "1.2.0",
"html2canvas": "1.0.0-rc.5", "html2canvas": "1.0.0-rc.5",
"i18next": "19.8.4",
"i18next-browser-languagedetector": "6.0.1",
"i18next-http-backend": "1.0.23",
"immutable": "4.0.0-rc.12", "immutable": "4.0.0-rc.12",
"is-ci": "2.0.0", "is-ci": "2.0.0",
"jquery": "3.5.1", "jquery": "3.5.1",
@@ -86,6 +89,7 @@
"react-dnd-html5-backend": "9.4.0", "react-dnd-html5-backend": "9.4.0",
"react-dom": "16.13.1", "react-dom": "16.13.1",
"react-hotkeys": "2.0.0", "react-hotkeys": "2.0.0",
"react-i18next": "11.8.5",
"react-notification-system": "0.2.17", "react-notification-system": "0.2.17",
"react-redux": "7.1.3", "react-redux": "7.1.3",
"redux": "4.0.4", "redux": "4.0.4",
@@ -124,7 +128,7 @@
"@types/prop-types": "15.5.8", "@types/prop-types": "15.5.8",
"@types/puppeteer": "3.0.1", "@types/puppeteer": "3.0.1",
"@types/q": "1.5.1", "@types/q": "1.5.1",
"@types/react": "16.9.56", "@types/react": "17.0.0",
"@types/react-dom": "17.0.0", "@types/react-dom": "17.0.0",
"@types/react-notification-system": "0.2.39", "@types/react-notification-system": "0.2.39",
"@types/react-redux": "7.1.7", "@types/react-redux": "7.1.7",

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import {
TriggerDefinition, TriggerDefinition,
UserDefinedFunctionDefinition, UserDefinedFunctionDefinition,
} from "@azure/cosmos"; } from "@azure/cosmos";
import Q from "q";
import { CommandButtonComponentProps } from "../Explorer/Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../Explorer/Controls/CommandButton/CommandButtonComponent";
import Explorer from "../Explorer/Explorer"; import Explorer from "../Explorer/Explorer";
import { ConsoleData } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleData } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
@@ -92,6 +91,7 @@ export interface Database extends TreeNode {
onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void; onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void;
onSettingsClick: () => void; onSettingsClick: () => void;
loadOffer(): Promise<void>; loadOffer(): Promise<void>;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
} }
export interface CollectionBase extends TreeNode { export interface CollectionBase extends TreeNode {
@@ -109,7 +109,7 @@ export interface CollectionBase extends TreeNode {
onDocumentDBDocumentsClick(): void; onDocumentDBDocumentsClick(): void;
onNewQueryClick(source: any, event: MouseEvent, queryText?: string): void; onNewQueryClick(source: any, event: MouseEvent, queryText?: string): void;
expandCollection(): Q.Promise<any>; expandCollection(): void;
collapseCollection(): void; collapseCollection(): void;
getDatabase(): Database; getDatabase(): Database;
} }
@@ -138,7 +138,6 @@ export interface Collection extends CollectionBase {
openTab(): void; openTab(): void;
onSettingsClick: () => Promise<void>; onSettingsClick: () => Promise<void>;
onDeleteCollectionContextMenuClick(source: Collection, event: MouseEvent): void;
onNewGraphClick(): void; onNewGraphClick(): void;
onNewMongoQueryClick(source: any, event: MouseEvent, queryText?: string): void; onNewMongoQueryClick(source: any, event: MouseEvent, queryText?: string): void;
@@ -176,9 +175,10 @@ export interface Collection extends CollectionBase {
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void; onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
onDrop(source: Collection, event: { originalEvent: DragEvent }): void; onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
uploadFiles(fileList: FileList): Q.Promise<UploadDetails>; uploadFiles(fileList: FileList): Promise<UploadDetails>;
getLabel(): string; getLabel(): string;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
} }
/** /**
@@ -293,10 +293,6 @@ export interface DocumentsTabOptions extends TabOptions {
resourceTokenPartitionKey?: string; resourceTokenPartitionKey?: string;
} }
export interface SettingsTabV2Options extends TabOptions {
getPendingNotification: Q.Promise<DataModels.Notification>;
}
export interface ConflictsTabOptions extends TabOptions { export interface ConflictsTabOptions extends TabOptions {
partitionKey: DataModels.PartitionKey; partitionKey: DataModels.PartitionKey;
conflictIds: ko.ObservableArray<ConflictId>; conflictIds: ko.ObservableArray<ConflictId>;
@@ -363,7 +359,8 @@ export enum CollectionTabKind {
Gallery = 17, Gallery = 17,
NotebookViewer = 18, NotebookViewer = 18,
Schema = 19, Schema = 19,
SettingsV2 = 20, CollectionSettingsV2 = 20,
DatabaseSettingsV2 = 21,
} }
export enum TerminalKind { export enum TerminalKind {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,35 +17,28 @@ exports[`CodeOfConductComponent renders 1`] = `
} }
} }
> >
Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement Azure Cosmos DB Notebook Gallery - Code of Conduct
</Text> </Text>
</StackItem> </StackItem>
<StackItem> <StackItem>
<Text> <Text>
Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB. The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.
</Text> </Text>
</StackItem> </StackItem>
<StackItem> <StackItem>
<Text> <Text>
In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the In order to view and publish your samples to the gallery, you must accept the
<StyledLinkBase <StyledLinkBase
href="https://aka.ms/cosmos-code-of-conduct" href="https://aka.ms/cosmos-code-of-conduct"
target="_blank" target="_blank"
> >
code of conduct code of conduct.
</StyledLinkBase>
and
<StyledLinkBase
href="https://aka.ms/ms-privacy-policy"
target="_blank"
>
privacy statement
</StyledLinkBase> </StyledLinkBase>
</Text> </Text>
</StackItem> </StackItem>
<StackItem> <StackItem>
<StyledCheckboxBase <StyledCheckboxBase
label="I have read and accepted the code of conduct and privacy statement" label="I have read and accept the code of conduct."
onChange={[Function]} onChange={[Function]}
styles={ styles={
Object { Object {

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { shallow } from "enzyme";
import React from "react"; import React from "react";
import { SettingsComponentProps, SettingsComponent, SettingsComponentState } from "./SettingsComponent"; import { SettingsComponentProps, SettingsComponent, SettingsComponentState } from "./SettingsComponent";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import SettingsTabV2 from "../../Tabs/SettingsTabV2"; import { CollectionSettingsTabV2 } from "../../Tabs/SettingsTabV2";
import { collection } from "./TestUtils"; import { collection } from "./TestUtils";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import ko from "knockout"; import ko from "knockout";
@@ -31,25 +31,21 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
})); }));
import { updateOffer } from "../../../Common/dataAccess/updateOffer"; import { updateOffer } from "../../../Common/dataAccess/updateOffer";
import { MongoDBCollectionResource } from "../../../Utils/arm/generatedClients/2020-04-01/types"; import { MongoDBCollectionResource } from "../../../Utils/arm/generatedClients/2020-04-01/types";
import Q from "q";
jest.mock("../../../Common/dataAccess/updateOffer", () => ({ jest.mock("../../../Common/dataAccess/updateOffer", () => ({
updateOffer: jest.fn().mockReturnValue({} as DataModels.Offer), updateOffer: jest.fn().mockReturnValue({} as DataModels.Offer),
})); }));
describe("SettingsComponent", () => { describe("SettingsComponent", () => {
const baseProps: SettingsComponentProps = { const baseProps: SettingsComponentProps = {
settingsTab: new SettingsTabV2({ settingsTab: new CollectionSettingsTabV2({
collection: collection, collection: collection,
tabKind: ViewModels.CollectionTabKind.SettingsV2, tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
title: "Scale & Settings", title: "Scale & Settings",
tabPath: "", tabPath: "",
node: undefined, node: undefined,
hashLocation: "settings", hashLocation: "settings",
isActive: ko.observable(false), isActive: ko.observable(false),
onUpdateTabsButtons: undefined, onUpdateTabsButtons: undefined,
getPendingNotification: Q.Promise<DataModels.Notification>(() => {
return;
}),
}), }),
}; };
@@ -142,6 +138,7 @@ describe("SettingsComponent", () => {
readSettings: undefined, readSettings: undefined,
onSettingsClick: undefined, onSettingsClick: undefined,
loadOffer: undefined, loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database; } as ViewModels.Database;
newCollection.getDatabase = () => newDatabase; newCollection.getDatabase = () => newDatabase;
newCollection.offer = ko.observable(undefined); newCollection.offer = ko.observable(undefined);

View File

@@ -11,7 +11,7 @@ import Explorer from "../../Explorer";
import { updateOffer } from "../../../Common/dataAccess/updateOffer"; import { updateOffer } from "../../../Common/dataAccess/updateOffer";
import { updateCollection, updateMongoDBCollectionThroughRP } from "../../../Common/dataAccess/updateCollection"; import { updateCollection, updateMongoDBCollectionThroughRP } from "../../../Common/dataAccess/updateCollection";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import SettingsTab from "../../Tabs/SettingsTabV2"; import { SettingsTabV2 } from "../../Tabs/SettingsTabV2";
import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils"; import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils";
import { ScaleComponent, ScaleComponentProps } from "./SettingsSubComponents/ScaleComponent"; import { ScaleComponent, ScaleComponentProps } from "./SettingsSubComponents/ScaleComponent";
import { import {
@@ -58,7 +58,7 @@ interface ButtonV2 {
} }
export interface SettingsComponentProps { export interface SettingsComponentProps {
settingsTab: SettingsTab; settingsTab: SettingsTabV2;
} }
export interface SettingsComponentState { export interface SettingsComponentState {
@@ -116,7 +116,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private discardSettingsChangesButton: ButtonV2; private discardSettingsChangesButton: ButtonV2;
private isAnalyticalStorageEnabled: boolean; private isAnalyticalStorageEnabled: boolean;
private isCollectionSettingsTab: boolean;
private collection: ViewModels.Collection; private collection: ViewModels.Collection;
private database: ViewModels.Database;
private offer: DataModels.Offer;
private container: Explorer; private container: Explorer;
private changeFeedPolicyVisible: boolean; private changeFeedPolicyVisible: boolean;
private isFixedContainer: boolean; private isFixedContainer: boolean;
@@ -126,20 +129,28 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
constructor(props: SettingsComponentProps) { constructor(props: SettingsComponentProps) {
super(props); super(props);
this.collection = this.props.settingsTab.collection as ViewModels.Collection; this.isCollectionSettingsTab = this.props.settingsTab.tabKind === ViewModels.CollectionTabKind.CollectionSettingsV2;
this.container = this.collection?.container; if (this.isCollectionSettingsTab) {
this.isAnalyticalStorageEnabled = !!this.collection?.analyticalStorageTtl(); this.collection = this.props.settingsTab.collection as ViewModels.Collection;
this.shouldShowIndexingPolicyEditor = this.container = this.collection?.container;
this.container && !this.container.isPreferredApiCassandra() && !this.container.isPreferredApiMongoDB(); 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( this.changeFeedPolicyVisible = this.collection?.container.isFeatureEnabled(
Constants.Features.enableChangeFeedPolicy Constants.Features.enableChangeFeedPolicy
); );
// Mongo container with system partition key still treat as "Fixed" // Mongo container with system partition key still treat as "Fixed"
this.isFixedContainer = this.isFixedContainer =
this.container.isPreferredApiMongoDB() && this.container.isPreferredApiMongoDB() &&
(!this.collection.partitionKey || this.collection.partitionKey.systemKey); (!this.collection?.partitionKey || this.collection?.partitionKey.systemKey);
} else {
this.database = this.props.settingsTab.database;
this.container = this.database?.container;
this.offer = this.database?.offer();
}
this.state = { this.state = {
throughput: undefined, throughput: undefined,
@@ -206,18 +217,21 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
} }
componentDidMount(): void { componentDidMount(): void {
this.refreshIndexTransformationProgress(); if (this.isCollectionSettingsTab) {
this.loadMongoIndexes(); this.refreshIndexTransformationProgress();
this.loadMongoIndexes();
}
this.setAutoPilotStates(); this.setAutoPilotStates();
this.setBaseline(); this.setBaseline();
if (this.props.settingsTab.isActive()) { if (this.props.settingsTab.isActive()) {
this.props.settingsTab.getSettingsTabContainer().onUpdateTabsButtons(this.getTabsButtons()); this.props.settingsTab.getContainer().onUpdateTabsButtons(this.getTabsButtons());
} }
} }
componentDidUpdate(): void { componentDidUpdate(): void {
if (this.props.settingsTab.isActive()) { if (this.props.settingsTab.isActive()) {
this.props.settingsTab.getSettingsTabContainer().onUpdateTabsButtons(this.getTabsButtons()); this.props.settingsTab.getContainer().onUpdateTabsButtons(this.getTabsButtons());
} }
} }
@@ -270,7 +284,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}; };
private setAutoPilotStates = (): void => { private setAutoPilotStates = (): void => {
const autoscaleMaxThroughput = this.collection?.offer()?.autoscaleMaxThroughput; const autoscaleMaxThroughput = this.offer?.autoscaleMaxThroughput;
if (autoscaleMaxThroughput && AutoPilotUtils.isValidAutoPilotThroughput(autoscaleMaxThroughput)) { if (autoscaleMaxThroughput && AutoPilotUtils.isValidAutoPilotThroughput(autoscaleMaxThroughput)) {
this.setState({ this.setState({
@@ -295,7 +309,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
!!this.collection.conflictResolutionPolicy(); !!this.collection.conflictResolutionPolicy();
public isOfferReplacePending = (): boolean => { public isOfferReplacePending = (): boolean => {
return this.collection?.offer()?.offerReplacePending; return this.offer?.offerReplacePending;
}; };
public onSaveClick = async (): Promise<void> => { public onSaveClick = async (): Promise<void> => {
@@ -309,174 +323,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
tabTitle: this.props.settingsTab.tabTitle(), tabTitle: this.props.settingsTab.tabTitle(),
}); });
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
try { try {
if ( await (this.isCollectionSettingsTab
this.state.isSubSettingsSaveable || ? this.saveCollectionSettings(startKey)
this.state.isIndexingPolicyDirty || : this.saveDatabaseSettings(startKey));
this.state.isConflictResolutionDirty
) {
let defaultTtl: number;
switch (this.state.timeToLive) {
case TtlType.On:
defaultTtl = Number(this.state.timeToLiveSeconds);
break;
case TtlType.OnNoDefault:
defaultTtl = -1;
break;
case TtlType.Off:
default:
defaultTtl = undefined;
break;
}
const wasIndexingPolicyModified = this.state.isIndexingPolicyDirty;
newCollection.defaultTtl = defaultTtl;
newCollection.indexingPolicy = this.state.indexingPolicyContent;
newCollection.changeFeedPolicy =
this.changeFeedPolicyVisible && this.state.changeFeedPolicy === ChangeFeedPolicyState.On
? {
retentionDuration: Constants.BackendDefaults.maxChangeFeedRetentionDuration,
}
: undefined;
newCollection.analyticalStorageTtl = this.getAnalyticalStorageTtl();
newCollection.geospatialConfig = {
type: this.state.geospatialConfigType,
};
const conflictResolutionChanges: DataModels.ConflictResolutionPolicy = this.getUpdatedConflictResolutionPolicy();
if (conflictResolutionChanges) {
newCollection.conflictResolutionPolicy = conflictResolutionChanges;
}
const updatedCollection: DataModels.Collection = await updateCollection(
this.collection.databaseId,
this.collection.id(),
newCollection
);
this.collection.rawDataModel = updatedCollection;
this.collection.defaultTtl(updatedCollection.defaultTtl);
this.collection.analyticalStorageTtl(updatedCollection.analyticalStorageTtl);
this.collection.id(updatedCollection.id);
this.collection.indexingPolicy(updatedCollection.indexingPolicy);
this.collection.conflictResolutionPolicy(updatedCollection.conflictResolutionPolicy);
this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy);
this.collection.geospatialConfig(updatedCollection.geospatialConfig);
if (wasIndexingPolicyModified) {
await this.refreshIndexTransformationProgress();
}
this.setState({
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isIndexingPolicyDirty: false,
isConflictResolutionDirty: false,
});
}
if (this.state.isMongoIndexingPolicySaveable && this.mongoDBCollectionResource) {
try {
const newMongoIndexes = this.getMongoIndexesToSave();
const newMongoCollection: MongoDBCollectionResource = {
...this.mongoDBCollectionResource,
indexes: newMongoIndexes,
};
this.mongoDBCollectionResource = await updateMongoDBCollectionThroughRP(
this.collection.databaseId,
this.collection.id(),
newMongoCollection
);
await this.refreshIndexTransformationProgress();
this.setState({
isMongoIndexingPolicySaveable: false,
indexesToDrop: [],
indexesToAdd: [],
currentMongoIndexes: [...this.mongoDBCollectionResource.indexes],
});
traceSuccess(
Action.MongoIndexUpdated,
{
databaseAccountName: this.container.databaseAccount()?.name,
databaseName: this.collection?.databaseId,
collectionName: this.collection?.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.props.settingsTab.tabTitle(),
},
startKey
);
} catch (error) {
traceFailure(
Action.MongoIndexUpdated,
{
databaseAccountName: this.container.databaseAccount()?.name,
databaseName: this.collection?.databaseId,
collectionName: this.collection?.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.props.settingsTab.tabTitle(),
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
throw error;
}
}
if (this.state.isScaleSaveable) {
const updateOfferParams: DataModels.UpdateOfferParams = {
databaseId: this.collection.databaseId,
collectionId: this.collection.id(),
currentOffer: this.collection.offer(),
autopilotThroughput: this.state.isAutoPilotSelected ? this.state.autoPilotThroughput : undefined,
manualThroughput: this.state.isAutoPilotSelected ? undefined : this.state.throughput,
};
if (this.hasProvisioningTypeChanged()) {
if (this.state.isAutoPilotSelected) {
updateOfferParams.migrateToAutoPilot = true;
} else {
updateOfferParams.migrateToManual = true;
}
}
const updatedOffer: DataModels.Offer = await updateOffer(updateOfferParams);
this.collection.offer(updatedOffer);
this.setState({ isScaleSaveable: false, isScaleDiscardable: false });
if (this.state.isAutoPilotSelected) {
this.setState({
autoPilotThroughput: updatedOffer.autoscaleMaxThroughput,
autoPilotThroughputBaseline: updatedOffer.autoscaleMaxThroughput,
});
} else {
this.setState({
throughput: updatedOffer.manualThroughput,
throughputBaseline: updatedOffer.manualThroughput,
});
}
}
this.container.isRefreshingExplorer(false);
this.setBaseline();
this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected });
traceSuccess(
Action.SettingsV2Updated,
{
databaseAccountName: this.container.databaseAccount()?.name,
databaseName: this.collection?.databaseId,
collectionName: this.collection?.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.props.settingsTab.tabTitle(),
},
startKey
);
} catch (error) { } catch (error) {
this.container.isRefreshingExplorer(false); this.container.isRefreshingExplorer(false);
this.props.settingsTab.isExecutionError(true); this.props.settingsTab.isExecutionError(true);
@@ -495,8 +345,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}, },
startKey startKey
); );
} finally {
this.props.settingsTab.isExecuting(false);
} }
this.props.settingsTab.isExecuting(false);
}; };
public onRevertClick = (): void => { public onRevertClick = (): void => {
@@ -693,6 +544,17 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}; };
public setBaseline = (): void => { public setBaseline = (): void => {
const offerThroughput = this.offer?.manualThroughput;
if (!this.isCollectionSettingsTab) {
this.setState({
throughput: offerThroughput,
throughputBaseline: offerThroughput,
});
return;
}
const defaultTtl = this.collection.defaultTtl(); const defaultTtl = this.collection.defaultTtl();
let timeToLive: TtlType = this.state.timeToLive; let timeToLive: TtlType = this.state.timeToLive;
@@ -725,7 +587,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
} }
} }
const offerThroughput = this.collection.offer()?.manualThroughput;
const changeFeedPolicy = this.collection.rawDataModel?.changeFeedPolicy const changeFeedPolicy = this.collection.rawDataModel?.changeFeedPolicy
? ChangeFeedPolicyState.On ? ChangeFeedPolicyState.On
: ChangeFeedPolicyState.Off; : ChangeFeedPolicyState.Off;
@@ -811,9 +672,225 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({ selectedTab: selectedTab }); this.setState({ selectedTab: selectedTab });
}; };
private saveDatabaseSettings = async (startKey: number): Promise<void> => {
if (this.state.isScaleSaveable) {
const updateOfferParams: DataModels.UpdateOfferParams = {
databaseId: this.database.id(),
currentOffer: this.database.offer(),
autopilotThroughput: this.state.isAutoPilotSelected ? this.state.autoPilotThroughput : undefined,
manualThroughput: this.state.isAutoPilotSelected ? undefined : this.state.throughput,
};
if (this.hasProvisioningTypeChanged()) {
if (this.state.isAutoPilotSelected) {
updateOfferParams.migrateToAutoPilot = true;
} else {
updateOfferParams.migrateToManual = true;
}
}
const updatedOffer: DataModels.Offer = await updateOffer(updateOfferParams);
this.database.offer(updatedOffer);
this.offer = updatedOffer;
this.setState({ isScaleSaveable: false, isScaleDiscardable: false });
if (this.state.isAutoPilotSelected) {
this.setState({
autoPilotThroughput: updatedOffer.autoscaleMaxThroughput,
autoPilotThroughputBaseline: updatedOffer.autoscaleMaxThroughput,
});
} else {
this.setState({
throughput: updatedOffer.manualThroughput,
throughputBaseline: updatedOffer.manualThroughput,
});
}
}
this.container.isRefreshingExplorer(false);
this.setBaseline();
this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected });
traceSuccess(
Action.SettingsV2Updated,
{
databaseAccountName: this.container.databaseAccount()?.name,
databaseName: this.database.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.props.settingsTab.tabTitle(),
},
startKey
);
};
private saveCollectionSettings = async (startKey: number): Promise<void> => {
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
if (this.state.isSubSettingsSaveable || this.state.isIndexingPolicyDirty || this.state.isConflictResolutionDirty) {
let defaultTtl: number;
switch (this.state.timeToLive) {
case TtlType.On:
defaultTtl = Number(this.state.timeToLiveSeconds);
break;
case TtlType.OnNoDefault:
defaultTtl = -1;
break;
case TtlType.Off:
default:
defaultTtl = undefined;
break;
}
const wasIndexingPolicyModified = this.state.isIndexingPolicyDirty;
newCollection.defaultTtl = defaultTtl;
newCollection.indexingPolicy = this.state.indexingPolicyContent;
newCollection.changeFeedPolicy =
this.changeFeedPolicyVisible && this.state.changeFeedPolicy === ChangeFeedPolicyState.On
? {
retentionDuration: Constants.BackendDefaults.maxChangeFeedRetentionDuration,
}
: undefined;
newCollection.analyticalStorageTtl = this.getAnalyticalStorageTtl();
newCollection.geospatialConfig = {
type: this.state.geospatialConfigType,
};
const conflictResolutionChanges: DataModels.ConflictResolutionPolicy = this.getUpdatedConflictResolutionPolicy();
if (conflictResolutionChanges) {
newCollection.conflictResolutionPolicy = conflictResolutionChanges;
}
const updatedCollection: DataModels.Collection = await updateCollection(
this.collection.databaseId,
this.collection.id(),
newCollection
);
this.collection.rawDataModel = updatedCollection;
this.collection.defaultTtl(updatedCollection.defaultTtl);
this.collection.analyticalStorageTtl(updatedCollection.analyticalStorageTtl);
this.collection.id(updatedCollection.id);
this.collection.indexingPolicy(updatedCollection.indexingPolicy);
this.collection.conflictResolutionPolicy(updatedCollection.conflictResolutionPolicy);
this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy);
this.collection.geospatialConfig(updatedCollection.geospatialConfig);
if (wasIndexingPolicyModified) {
await this.refreshIndexTransformationProgress();
}
this.setState({
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isIndexingPolicyDirty: false,
isConflictResolutionDirty: false,
});
}
if (this.state.isMongoIndexingPolicySaveable && this.mongoDBCollectionResource) {
try {
const newMongoIndexes = this.getMongoIndexesToSave();
const newMongoCollection: MongoDBCollectionResource = {
...this.mongoDBCollectionResource,
indexes: newMongoIndexes,
};
this.mongoDBCollectionResource = await updateMongoDBCollectionThroughRP(
this.collection.databaseId,
this.collection.id(),
newMongoCollection
);
await this.refreshIndexTransformationProgress();
this.setState({
isMongoIndexingPolicySaveable: false,
indexesToDrop: [],
indexesToAdd: [],
currentMongoIndexes: [...this.mongoDBCollectionResource.indexes],
});
traceSuccess(
Action.MongoIndexUpdated,
{
databaseAccountName: this.container.databaseAccount()?.name,
databaseName: this.collection?.databaseId,
collectionName: this.collection?.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.props.settingsTab.tabTitle(),
},
startKey
);
} catch (error) {
traceFailure(
Action.MongoIndexUpdated,
{
databaseAccountName: this.container.databaseAccount()?.name,
databaseName: this.collection?.databaseId,
collectionName: this.collection?.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.props.settingsTab.tabTitle(),
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
throw error;
}
}
if (this.state.isScaleSaveable) {
const updateOfferParams: DataModels.UpdateOfferParams = {
databaseId: this.collection.databaseId,
collectionId: this.collection.id(),
currentOffer: this.collection.offer(),
autopilotThroughput: this.state.isAutoPilotSelected ? this.state.autoPilotThroughput : undefined,
manualThroughput: this.state.isAutoPilotSelected ? undefined : this.state.throughput,
};
if (this.hasProvisioningTypeChanged()) {
if (this.state.isAutoPilotSelected) {
updateOfferParams.migrateToAutoPilot = true;
} else {
updateOfferParams.migrateToManual = true;
}
}
const updatedOffer: DataModels.Offer = await updateOffer(updateOfferParams);
this.collection.offer(updatedOffer);
this.offer = updatedOffer;
this.setState({ isScaleSaveable: false, isScaleDiscardable: false });
if (this.state.isAutoPilotSelected) {
this.setState({
autoPilotThroughput: updatedOffer.autoscaleMaxThroughput,
autoPilotThroughputBaseline: updatedOffer.autoscaleMaxThroughput,
});
} else {
this.setState({
throughput: updatedOffer.manualThroughput,
throughputBaseline: updatedOffer.manualThroughput,
});
}
}
this.container.isRefreshingExplorer(false);
this.setBaseline();
this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected });
traceSuccess(
Action.SettingsV2Updated,
{
databaseAccountName: this.container.databaseAccount()?.name,
databaseName: this.collection?.databaseId,
collectionName: this.collection?.id(),
defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: this.props.settingsTab.tabTitle(),
},
startKey
);
};
public render(): JSX.Element { public render(): JSX.Element {
const scaleComponentProps: ScaleComponentProps = { const scaleComponentProps: ScaleComponentProps = {
collection: this.collection, collection: this.collection,
database: this.database,
container: this.container, container: this.container,
isFixedContainer: this.isFixedContainer, isFixedContainer: this.isFixedContainer,
onThroughputChange: this.onThroughputChange, onThroughputChange: this.onThroughputChange,
@@ -830,6 +907,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
initialNotification: this.props.settingsTab.pendingNotification(), initialNotification: this.props.settingsTab.pendingNotification(),
}; };
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">
<div className="settingsV2TabsContainer">
<ScaleComponent {...scaleComponentProps} />
</div>
</div>
);
}
const subSettingsComponentProps: SubSettingsComponentProps = { const subSettingsComponentProps: SubSettingsComponentProps = {
collection: this.collection, collection: this.collection,
container: this.container, container: this.container,
@@ -899,7 +986,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
}; };
const tabs: SettingsV2TabInfo[] = []; const tabs: SettingsV2TabInfo[] = [];
if (!hasDatabaseSharedThroughput(this.collection) && this.collection.offer()) { if (!hasDatabaseSharedThroughput(this.collection) && this.offer) {
tabs.push({ tabs.push({
tab: SettingsV2TabTypes.ScaleTab, tab: SettingsV2TabTypes.ScaleTab,
content: <ScaleComponent {...scaleComponentProps} />, content: <ScaleComponent {...scaleComponentProps} />,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,11 +23,7 @@ exports[`ScaleComponent renders with correct initial notification 1`] = `
> >
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications. A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
<br /> <br />
Database: Database: test, Container: test
test
, Container:
test
, Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s , Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s
</Text> </Text>
</StyledMessageBarBase> </StyledMessageBarBase>

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,10 @@ describe("SmartUiComponent", () => {
root: { root: {
id: "root", id: "root",
info: { info: {
message: "Start at $24/mo per database", messageTKey: "Start at $24/mo per database",
link: { link: {
href: "https://aka.ms/azure-cosmos-db-pricing", href: "https://aka.ms/azure-cosmos-db-pricing",
text: "More Details", textTKey: "More Details",
}, },
}, },
children: [ children: [
@@ -21,10 +21,10 @@ describe("SmartUiComponent", () => {
dataFieldName: "description", dataFieldName: "description",
type: "string", type: "string",
description: { description: {
text: "this is an example description text.", textTKey: "this is an example description text.",
link: { link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
text: "Click here for more information.", textTKey: "Click here for more information.",
}, },
}, },
}, },
@@ -32,7 +32,7 @@ describe("SmartUiComponent", () => {
{ {
id: "throughput", id: "throughput",
input: { input: {
label: "Throughput (input)", labelTKey: "Throughput (input)",
dataFieldName: "throughput", dataFieldName: "throughput",
type: "number", type: "number",
min: 400, min: 400,
@@ -45,7 +45,7 @@ describe("SmartUiComponent", () => {
{ {
id: "throughput2", id: "throughput2",
input: { input: {
label: "Throughput (Slider)", labelTKey: "Throughput (Slider)",
dataFieldName: "throughput2", dataFieldName: "throughput2",
type: "number", type: "number",
min: 400, min: 400,
@@ -58,7 +58,7 @@ describe("SmartUiComponent", () => {
{ {
id: "throughput3", id: "throughput3",
input: { input: {
label: "Throughput (invalid)", labelTKey: "Throughput (invalid)",
dataFieldName: "throughput3", dataFieldName: "throughput3",
type: "boolean", type: "boolean",
min: 400, min: 400,
@@ -72,7 +72,7 @@ describe("SmartUiComponent", () => {
{ {
id: "containerId", id: "containerId",
input: { input: {
label: "Container id", labelTKey: "Container id",
dataFieldName: "containerId", dataFieldName: "containerId",
type: "string", type: "string",
}, },
@@ -80,9 +80,9 @@ describe("SmartUiComponent", () => {
{ {
id: "analyticalStore", id: "analyticalStore",
input: { input: {
label: "Analytical Store", labelTKey: "Analytical Store",
trueLabel: "Enabled", trueLabelTKey: "Enabled",
falseLabel: "Disabled", falseLabelTKey: "Disabled",
defaultValue: true, defaultValue: true,
dataFieldName: "analyticalStore", dataFieldName: "analyticalStore",
type: "boolean", type: "boolean",
@@ -91,7 +91,7 @@ describe("SmartUiComponent", () => {
{ {
id: "database", id: "database",
input: { input: {
label: "Database", labelTKey: "Database",
dataFieldName: "database", dataFieldName: "database",
type: "object", type: "object",
choices: [ choices: [
@@ -117,6 +117,9 @@ describe("SmartUiComponent", () => {
onError={() => { onError={() => {
return; return;
}} }}
getTranslation={(key: string) => {
return key;
}}
/> />
); );
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
@@ -145,6 +148,9 @@ describe("SmartUiComponent", () => {
onError={() => { onError={() => {
return; return;
}} }}
getTranslation={(key: string) => {
return key;
}}
/> />
); );
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));

View File

@@ -18,6 +18,7 @@ import {
NumberUiType, NumberUiType,
SmartUiInput, SmartUiInput,
} from "../../../SelfServe/SelfServeTypes"; } from "../../../SelfServe/SelfServeTypes";
import { TFunction } from "i18next";
/** /**
* Generic UX renderer * Generic UX renderer
@@ -34,8 +35,8 @@ interface BaseDisplay {
} }
interface BaseInput extends BaseDisplay { interface BaseInput extends BaseDisplay {
label: string; labelTKey: string;
placeholder?: string; placeholderTKey?: string;
errorMessage?: string; errorMessage?: string;
} }
@@ -51,8 +52,8 @@ interface NumberInput extends BaseInput {
} }
interface BooleanInput extends BaseInput { interface BooleanInput extends BaseInput {
trueLabel: string; trueLabelTKey: string;
falseLabel: string; falseLabelTKey: string;
defaultValue?: boolean; defaultValue?: boolean;
} }
@@ -89,6 +90,7 @@ export interface SmartUiComponentProps {
onInputChange: (input: AnyDisplay, newValue: InputType) => void; onInputChange: (input: AnyDisplay, newValue: InputType) => void;
onError: (hasError: boolean) => void; onError: (hasError: boolean) => void;
disabled: boolean; disabled: boolean;
getTranslation: TFunction;
} }
interface SmartUiComponentState { interface SmartUiComponentState {
@@ -122,10 +124,10 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
private renderInfo(info: Info): JSX.Element { private renderInfo(info: Info): JSX.Element {
return ( return (
<MessageBar styles={{ root: { width: 400 } }}> <MessageBar styles={{ root: { width: 400 } }}>
{info.message} {this.props.getTranslation(info.messageTKey)}
{info.link && ( {info.link && (
<Link href={info.link.href} target="_blank"> <Link href={info.link.href} target="_blank">
{info.link.text} {this.props.getTranslation(info.link.textTKey)}
</Link> </Link>
)} )}
</MessageBar> </MessageBar>
@@ -139,10 +141,10 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
<div className="stringInputContainer"> <div className="stringInputContainer">
<TextField <TextField
id={`${input.dataFieldName}-textField-input`} id={`${input.dataFieldName}-textField-input`}
label={input.label} label={this.props.getTranslation(input.labelTKey)}
type="text" type="text"
value={value || ""} value={value || ""}
placeholder={input.placeholder} placeholder={this.props.getTranslation(input.placeholderTKey)}
disabled={disabled} disabled={disabled}
onChange={(_, newValue) => this.props.onInputChange(input, newValue)} onChange={(_, newValue) => this.props.onInputChange(input, newValue)}
styles={{ styles={{
@@ -165,10 +167,10 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
const description = input.description; const description = input.description;
return ( return (
<Text id={`${input.dataFieldName}-text-display`}> <Text id={`${input.dataFieldName}-text-display`}>
{input.description.text}{" "} {this.props.getTranslation(input.description.textTKey)}{" "}
{description.link && ( {description.link && (
<Link target="_blank" href={input.description.link.href}> <Link target="_blank" href={input.description.link.href}>
{input.description.link.text} {this.props.getTranslation(input.description.link.textTKey)}
</Link> </Link>
)} )}
</Text> </Text>
@@ -219,12 +221,12 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}; };
private renderNumberInput(input: NumberInput): JSX.Element { private renderNumberInput(input: NumberInput): JSX.Element {
const { label, min, max, dataFieldName, step } = input; const { labelTKey, min, max, dataFieldName, step } = input;
const props = { const props = {
label: label, label: this.props.getTranslation(labelTKey),
min: min, min: min,
max: max, max: max,
ariaLabel: label, ariaLabel: labelTKey,
step: step, step: step,
}; };
@@ -284,10 +286,10 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return ( return (
<Toggle <Toggle
id={`${input.dataFieldName}-toggle-input`} id={`${input.dataFieldName}-toggle-input`}
label={input.label} label={this.props.getTranslation(input.labelTKey)}
checked={value || false} checked={value || false}
onText={input.trueLabel} onText={this.props.getTranslation(input.trueLabelTKey)}
offText={input.falseLabel} offText={this.props.getTranslation(input.falseLabelTKey)}
disabled={disabled} disabled={disabled}
onChange={(event, checked: boolean) => this.props.onInputChange(input, checked)} onChange={(event, checked: boolean) => this.props.onInputChange(input, checked)}
styles={{ root: { width: 400 } }} styles={{ root: { width: 400 } }}
@@ -296,7 +298,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
} }
private renderChoiceInput(input: ChoiceInput): JSX.Element { private renderChoiceInput(input: ChoiceInput): JSX.Element {
const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input; const { labelTKey, defaultKey, dataFieldName, choices, placeholderTKey } = input;
const value = this.props.currentValues.get(dataFieldName)?.value as string; const value = this.props.currentValues.get(dataFieldName)?.value as string;
const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled; const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled;
let selectedKey = value ? value : defaultKey; let selectedKey = value ? value : defaultKey;
@@ -306,14 +308,14 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return ( return (
<Dropdown <Dropdown
id={`${input.dataFieldName}-dropdown-input`} id={`${input.dataFieldName}-dropdown-input`}
label={label} label={this.props.getTranslation(labelTKey)}
selectedKey={selectedKey} selectedKey={selectedKey}
onChange={(_, item: IDropdownOption) => this.props.onInputChange(input, item.key.toString())} onChange={(_, item: IDropdownOption) => this.props.onInputChange(input, item.key.toString())}
placeholder={placeholder} placeholder={this.props.getTranslation(placeholderTKey)}
disabled={disabled} disabled={disabled}
options={choices.map((c) => ({ options={choices.map((c) => ({
key: c.key, key: c.key,
text: c.label, text: this.props.getTranslation(c.label),
}))} }))}
styles={{ styles={{
root: { width: 400 }, root: { width: 400 },

View File

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

View File

@@ -36,7 +36,36 @@ export class CommandBarComponentButtonFactory {
} }
const newCollectionBtn = CommandBarComponentButtonFactory.createNewCollectionGroup(container); const newCollectionBtn = CommandBarComponentButtonFactory.createNewCollectionGroup(container);
const buttons: CommandButtonComponentProps[] = [newCollectionBtn]; const buttons: CommandButtonComponentProps[] = [];
if (container.isFeatureEnabled && container.isFeatureEnabled("regionselectbutton")) {
const regions = [{ name: "West US" }, { name: "East US" }, { name: "North Europe" }];
buttons.push({
iconSrc: null,
onCommandClick: () => {},
commandButtonLabel: null,
hasPopup: false,
isDropdown: true,
dropdownPlaceholder: "West US",
dropdownSelectedKey: "West US",
dropdownWidth: 100,
children: regions.map(
(region) =>
({
iconSrc: null,
onCommandClick: () => {},
commandButtonLabel: region.name,
dropdownItemKey: region.name,
hasPopup: false,
disabled: false,
ariaLabel: "",
} as CommandButtonComponentProps)
),
ariaLabel: "",
});
}
buttons.push(newCollectionBtn);
const addSynapseLink = CommandBarComponentButtonFactory.createOpenSynapseLinkDialogButton(container); const addSynapseLink = CommandBarComponentButtonFactory.createOpenSynapseLinkDialogButton(container);
if (addSynapseLink) { if (addSynapseLink) {

View File

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

View File

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

View File

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

View File

@@ -1,142 +0,0 @@
jest.mock("../../Common/dataAccess/deleteCollection");
import * as ko from "knockout";
import * as sinon from "sinon";
import Q from "q";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import DeleteCollectionConfirmationPane from "./DeleteCollectionConfirmationPane";
import DeleteFeedback from "../../Common/DeleteFeedback";
import Explorer from "../Explorer";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { TreeNode } from "../../Contracts/ViewModels";
import { deleteCollection } from "../../Common/dataAccess/deleteCollection";
describe("Delete Collection Confirmation Pane", () => {
describe("Explorer.isLastCollection()", () => {
let explorer: Explorer;
beforeEach(() => {
explorer = new Explorer();
});
it("should be true if 1 database and 1 collection", () => {
let database = {} as ViewModels.Database;
database.collections = ko.observableArray<ViewModels.Collection>([{} as ViewModels.Collection]);
explorer.databases = ko.observableArray<ViewModels.Database>([database]);
expect(explorer.isLastCollection()).toBe(true);
});
it("should be false if if 1 database and 2 collection", () => {
let database = {} as ViewModels.Database;
database.collections = ko.observableArray<ViewModels.Collection>([
{} as ViewModels.Collection,
{} as ViewModels.Collection,
]);
explorer.databases = ko.observableArray<ViewModels.Database>([database]);
expect(explorer.isLastCollection()).toBe(false);
});
it("should be false if 2 database and 1 collection each", () => {
let database = {} as ViewModels.Database;
database.collections = ko.observableArray<ViewModels.Collection>([{} as ViewModels.Collection]);
let database2 = {} as ViewModels.Database;
database2.collections = ko.observableArray<ViewModels.Collection>([{} as ViewModels.Collection]);
explorer.databases = ko.observableArray<ViewModels.Database>([database, database2]);
expect(explorer.isLastCollection()).toBe(false);
});
it("should be false if 0 databases", () => {
let database = {} as ViewModels.Database;
explorer.databases = ko.observableArray<ViewModels.Database>();
database.collections = ko.observableArray<ViewModels.Collection>();
expect(explorer.isLastCollection()).toBe(false);
});
});
describe("shouldRecordFeedback()", () => {
it("should return true if last collection and database does not have shared throughput else false", () => {
let fakeExplorer = new Explorer();
fakeExplorer.refreshAllDatabases = () => Q.resolve();
let pane = new DeleteCollectionConfirmationPane({
id: "deletecollectionconfirmationpane",
visible: ko.observable<boolean>(false),
container: fakeExplorer,
});
fakeExplorer.isLastCollection = () => true;
fakeExplorer.isSelectedDatabaseShared = () => false;
expect(pane.shouldRecordFeedback()).toBe(true);
fakeExplorer.isLastCollection = () => true;
fakeExplorer.isSelectedDatabaseShared = () => true;
expect(pane.shouldRecordFeedback()).toBe(false);
fakeExplorer.isLastCollection = () => false;
fakeExplorer.isSelectedDatabaseShared = () => false;
expect(pane.shouldRecordFeedback()).toBe(false);
});
});
describe("submit()", () => {
let telemetryProcessorSpy: sinon.SinonSpy;
beforeEach(() => {
(deleteCollection as jest.Mock).mockResolvedValue(undefined);
telemetryProcessorSpy = sinon.spy(TelemetryProcessor, "trace");
});
afterEach(() => {
telemetryProcessorSpy.restore();
});
it("it should log feedback if last collection and database is not shared", () => {
let selectedCollectionId = "testCol";
let fakeExplorer = {} as Explorer;
fakeExplorer.findSelectedCollection = () => {
return {
id: ko.observable<string>(selectedCollectionId),
rid: "test",
} as ViewModels.Collection;
};
fakeExplorer.selectedCollectionId = ko.computed<string>(() => selectedCollectionId);
fakeExplorer.isSelectedDatabaseShared = () => false;
const SubscriptionId = "testId";
const AccountName = "testAccount";
fakeExplorer.databaseAccount = ko.observable<DataModels.DatabaseAccount>({
id: SubscriptionId,
name: AccountName,
} as DataModels.DatabaseAccount);
fakeExplorer.defaultExperience = ko.observable<string>("DocumentDB");
fakeExplorer.isPreferredApiCassandra = ko.computed(() => {
return false;
});
fakeExplorer.selectedNode = ko.observable<TreeNode>();
fakeExplorer.isLastCollection = () => true;
fakeExplorer.isSelectedDatabaseShared = () => false;
fakeExplorer.refreshAllDatabases = () => Q.resolve();
let pane = new DeleteCollectionConfirmationPane({
id: "deletecollectionconfirmationpane",
visible: ko.observable<boolean>(false),
container: fakeExplorer as any,
});
pane.collectionIdConfirmation = ko.observable<string>(selectedCollectionId);
const Feedback = "my feedback";
pane.containerDeleteFeedback(Feedback);
return pane.submit().then(() => {
expect(telemetryProcessorSpy.called).toBe(true);
let deleteFeedback = new DeleteFeedback(SubscriptionId, AccountName, DataModels.ApiKind.SQL, Feedback);
expect(
telemetryProcessorSpy.calledWith(Action.DeleteCollection, ActionModifiers.Mark, {
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)),
})
).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,174 @@
jest.mock("../../Common/dataAccess/deleteCollection");
jest.mock("../../Shared/Telemetry/TelemetryProcessor");
import * as ko from "knockout";
import { ApiKind, DatabaseAccount } from "../../Contracts/DataModels";
import { Collection, Database } from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { mount, ReactWrapper, shallow } from "enzyme";
import React from "react";
import DeleteFeedback from "../../Common/DeleteFeedback";
import Explorer from "../Explorer";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { TreeNode } from "../../Contracts/ViewModels";
import { deleteCollection } from "../../Common/dataAccess/deleteCollection";
import { DeleteCollectionConfirmationPanel } from "./DeleteCollectionConfirmationPanel";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { updateUserContext } from "../../UserContext";
describe("Delete Collection Confirmation Pane", () => {
describe("Explorer.isLastCollection()", () => {
let explorer: Explorer;
beforeEach(() => {
explorer = new Explorer();
});
it("should be true if 1 database and 1 collection", () => {
const database = {} as Database;
database.collections = ko.observableArray<Collection>([{} as Collection]);
explorer.databases = ko.observableArray<Database>([database]);
expect(explorer.isLastCollection()).toBe(true);
});
it("should be false if if 1 database and 2 collection", () => {
const database = {} as Database;
database.collections = ko.observableArray<Collection>([{} as Collection, {} as Collection]);
explorer.databases = ko.observableArray<Database>([database]);
expect(explorer.isLastCollection()).toBe(false);
});
it("should be false if 2 database and 1 collection each", () => {
const database = {} as Database;
database.collections = ko.observableArray<Collection>([{} as Collection]);
const database2 = {} as Database;
database2.collections = ko.observableArray<Collection>([{} as Collection]);
explorer.databases = ko.observableArray<Database>([database, database2]);
expect(explorer.isLastCollection()).toBe(false);
});
it("should be false if 0 databases", () => {
const database = {} as Database;
explorer.databases = ko.observableArray<Database>();
database.collections = ko.observableArray<Collection>();
expect(explorer.isLastCollection()).toBe(false);
});
});
describe("shouldRecordFeedback()", () => {
it("should return true if last collection and database does not have shared throughput else false", () => {
const fakeExplorer = new Explorer();
fakeExplorer.refreshAllDatabases = () => undefined;
fakeExplorer.isLastCollection = () => true;
fakeExplorer.isSelectedDatabaseShared = () => false;
const props = {
explorer: fakeExplorer,
closePanel: (): void => undefined,
openNotificationConsole: (): void => undefined,
};
const wrapper = shallow(<DeleteCollectionConfirmationPanel {...props} />);
expect(wrapper.exists(".deleteCollectionFeedback")).toBe(true);
props.explorer.isLastCollection = () => true;
props.explorer.isSelectedDatabaseShared = () => true;
wrapper.setProps(props);
expect(wrapper.exists(".deleteCollectionFeedback")).toBe(false);
props.explorer.isLastCollection = () => false;
props.explorer.isSelectedDatabaseShared = () => false;
wrapper.setProps(props);
expect(wrapper.exists(".deleteCollectionFeedback")).toBe(false);
});
});
describe("submit()", () => {
let wrapper: ReactWrapper;
const selectedCollectionId = "testCol";
const databaseId = "testDatabase";
const fakeExplorer = {} as Explorer;
fakeExplorer.findSelectedCollection = () => {
return {
id: ko.observable<string>(selectedCollectionId),
databaseId,
rid: "test",
} as Collection;
};
fakeExplorer.selectedCollectionId = ko.computed<string>(() => selectedCollectionId);
fakeExplorer.selectedNode = ko.observable<TreeNode>();
fakeExplorer.refreshAllDatabases = () => undefined;
fakeExplorer.isLastCollection = () => true;
fakeExplorer.isSelectedDatabaseShared = () => false;
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "testDatabaseAccountName",
properties: {
cassandraEndpoint: "testEndpoint",
},
id: "testDatabaseAccountId",
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB,
});
(deleteCollection as jest.Mock).mockResolvedValue(undefined);
(TelemetryProcessor.trace as jest.Mock).mockReturnValue(undefined);
});
beforeEach(() => {
const props = {
explorer: fakeExplorer,
closePanel: (): void => undefined,
openNotificationConsole: (): void => undefined,
};
wrapper = mount(<DeleteCollectionConfirmationPanel {...props} />);
});
it("should call delete collection", () => {
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists("#confirmCollectionId")).toBe(true);
wrapper
.find("#confirmCollectionId")
.hostNodes()
.simulate("change", { target: { value: selectedCollectionId } });
expect(wrapper.exists("#sidePanelOkButton")).toBe(true);
wrapper.find("#sidePanelOkButton").hostNodes().simulate("click");
expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId);
wrapper.unmount();
});
it("should record feedback", async () => {
expect(wrapper.exists("#confirmCollectionId")).toBe(true);
wrapper
.find("#confirmCollectionId")
.hostNodes()
.simulate("change", { target: { value: selectedCollectionId } });
expect(wrapper.exists("#deleteCollectionFeedbackInput")).toBe(true);
const feedbackText = "Test delete collection feedback text";
wrapper
.find("#deleteCollectionFeedbackInput")
.hostNodes()
.simulate("change", { target: { value: feedbackText } });
expect(wrapper.exists("#sidePanelOkButton")).toBe(true);
wrapper.find("#sidePanelOkButton").hostNodes().simulate("click");
expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId);
const deleteFeedback = new DeleteFeedback(
"testDatabaseAccountId",
"testDatabaseAccountName",
ApiKind.SQL,
feedbackText
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(TelemetryProcessor.trace).toHaveBeenCalledWith(Action.DeleteCollection, ActionModifiers.Mark, {
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)),
});
wrapper.unmount();
});
});
});

View File

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

View File

@@ -0,0 +1,186 @@
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import * as React from "react";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { PanelFooterComponent } from "./PanelFooterComponent";
import { Collection } from "../../Contracts/ViewModels";
import { Text, TextField } from "office-ui-fabric-react";
import { userContext } from "../../UserContext";
import { Areas } from "../../Common/Constants";
import { deleteCollection } from "../../Common/dataAccess/deleteCollection";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
import { PanelErrorComponent, PanelErrorProps } from "./PanelErrorComponent";
import DeleteFeedback from "../../Common/DeleteFeedback";
import Explorer from "../Explorer";
import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif";
export interface DeleteCollectionConfirmationPanelProps {
explorer: Explorer;
closePanel: () => void;
openNotificationConsole: () => void;
}
export interface DeleteCollectionConfirmationPanelState {
formError: string;
isExecuting: boolean;
}
export class DeleteCollectionConfirmationPanel extends React.Component<
DeleteCollectionConfirmationPanelProps,
DeleteCollectionConfirmationPanelState
> {
private inputCollectionName: string;
private deleteCollectionFeedback: string;
constructor(props: DeleteCollectionConfirmationPanelProps) {
super(props);
this.state = {
formError: "",
isExecuting: false,
};
}
render(): JSX.Element {
return (
<div className="panelContentContainer">
<PanelErrorComponent {...this.getPanelErrorProps()} />
<div className="panelMainContent">
<div className="confirmDeleteInput">
<span className="mandatoryStar">* </span>
<Text variant="small">Confirm by typing the collection id</Text>
<TextField
id="confirmCollectionId"
autoFocus
styles={{ fieldGroup: { width: 300 } }}
onChange={(event, newInput?: string) => {
this.inputCollectionName = newInput;
}}
/>
</div>
{this.shouldRecordFeedback() && (
<div className="deleteCollectionFeedback">
<Text variant="small" block>
Help us improve Azure Cosmos DB!
</Text>
<Text variant="small" block>
What is the reason why you are deleting this container?
</Text>
<TextField
id="deleteCollectionFeedbackInput"
styles={{ fieldGroup: { width: 300 } }}
multiline
rows={3}
onChange={(event, newInput?: string) => {
this.deleteCollectionFeedback = newInput;
}}
/>
</div>
)}
</div>
<PanelFooterComponent buttonLabel="OK" onOKButtonClicked={() => this.submit()} />
<div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" hidden={!this.state.isExecuting}>
<img className="dataExplorerLoader" src={LoadingIndicator_3Squares} />
</div>
</div>
);
}
private getPanelErrorProps(): PanelErrorProps {
if (this.state.formError) {
return {
isWarning: false,
message: this.state.formError,
showErrorDetails: true,
openNotificationConsole: this.props.openNotificationConsole,
};
}
return {
isWarning: true,
showErrorDetails: false,
message:
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
};
}
private shouldRecordFeedback(): boolean {
return this.props.explorer.isLastCollection() && !this.props.explorer.isSelectedDatabaseShared();
}
public async submit(): Promise<void> {
const collection = this.props.explorer.findSelectedCollection();
if (!collection || this.inputCollectionName !== collection.id()) {
const errorMessage = "Input collection name does not match the selected collection";
this.setState({ formError: errorMessage });
NotificationConsoleUtils.logConsoleError(`Error while deleting collection ${collection.id()}: ${errorMessage}`);
return;
}
this.setState({ formError: "", isExecuting: true });
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteCollection, {
databaseAccountName: userContext.databaseAccount?.name,
defaultExperience: userContext.defaultExperience,
collectionId: collection.id(),
dataExplorerArea: Areas.ContextualPane,
paneTitle: "Delete Collection",
});
try {
await deleteCollection(collection.databaseId, collection.id());
this.setState({ isExecuting: false });
this.props.explorer.selectedNode(collection.database);
this.props.explorer.tabsManager?.closeTabsByComparator(
(tab) => tab.node?.id() === collection.id() && (tab.node as Collection).databaseId === collection.databaseId
);
this.props.explorer.refreshAllDatabases();
TelemetryProcessor.traceSuccess(
Action.DeleteCollection,
{
databaseAccountName: userContext.databaseAccount?.name,
defaultExperience: userContext.defaultExperience,
collectionId: collection.id(),
dataExplorerArea: Areas.ContextualPane,
paneTitle: "Delete Collection",
},
startKey
);
if (this.shouldRecordFeedback()) {
const deleteFeedback = new DeleteFeedback(
userContext.databaseAccount?.id,
userContext.databaseAccount?.name,
DefaultExperienceUtility.getApiKindFromDefaultExperience(userContext.defaultExperience),
this.deleteCollectionFeedback
);
TelemetryProcessor.trace(Action.DeleteCollection, ActionModifiers.Mark, {
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)),
});
}
this.props.closePanel();
} catch (error) {
const errorMessage = getErrorMessage(error);
this.setState({ formError: errorMessage, isExecuting: false });
TelemetryProcessor.traceFailure(
Action.DeleteCollection,
{
databaseAccountName: userContext.databaseAccount?.name,
defaultExperience: userContext.defaultExperience,
collectionId: collection.id(),
dataExplorerArea: Areas.ContextualPane,
paneTitle: "Delete Collection",
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey
);
}
}
}

View File

@@ -52,81 +52,71 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase {
dataExplorerArea: Constants.Areas.ContextualPane, dataExplorerArea: Constants.Areas.ContextualPane,
paneTitle: this.title(), paneTitle: this.title(),
}); });
// TODO: Should not be a Q promise anymore, but the Cassandra code requires it return Q(
let promise: Q.Promise<any>; deleteDatabase(selectedDatabase.id()).then(
if (this.container.isPreferredApiCassandra()) { () => {
promise = (<CassandraAPIDataClient>this.container.tableDataClient).deleteTableOrKeyspace( this.isExecuting(false);
this.container.databaseAccount().properties.cassandraEndpoint, this.close();
this.container.databaseAccount().id, this.container.refreshAllDatabases();
`DROP KEYSPACE ${selectedDatabase.id()};`, this.container.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id());
this.container this.container.selectedNode(null);
); selectedDatabase
} else { .collections()
promise = Q(deleteDatabase(selectedDatabase.id())); .forEach((collection: ViewModels.Collection) =>
} this.container.tabsManager.closeTabsByComparator(
return promise.then( (tab) =>
() => { tab.node?.id() === collection.id() &&
this.isExecuting(false); (tab.node as ViewModels.Collection).databaseId === collection.databaseId
this.close(); )
this.container.refreshAllDatabases(); );
this.container.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id()); this.resetData();
this.container.selectedNode(null); TelemetryProcessor.traceSuccess(
selectedDatabase Action.DeleteDatabase,
.collections() {
.forEach((collection: ViewModels.Collection) => databaseAccountName: this.container.databaseAccount().name,
this.container.tabsManager.closeTabsByComparator( defaultExperience: this.container.defaultExperience(),
(tab) => databaseId: selectedDatabase.id(),
tab.node?.id() === collection.id() && dataExplorerArea: Constants.Areas.ContextualPane,
(tab.node as ViewModels.Collection).databaseId === collection.databaseId paneTitle: this.title(),
) },
); startKey
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()
); );
TelemetryProcessor.trace(Action.DeleteDatabase, ActionModifiers.Mark, { if (this.shouldRecordFeedback()) {
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)), 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 { toJS } from "@nteract/commutable";
import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent"; import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
import { HttpStatusCodes } from "../../Common/Constants"; import { HttpStatusCodes } from "../../Common/Constants";
import { handleError, getErrorMessage } from "../../Common/ErrorHandlingUtils"; import { handleError, getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import { GalleryTab } from "../Controls/NotebookGallery/GalleryViewerComponent";
import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { FileSystemUtil } from "../Notebook/FileSystemUtil";
export class PublishNotebookPaneAdapter implements ReactAdapter { export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>; parameters: ko.Observable<number>;
@@ -66,7 +70,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
onChangeDescription: (newValue: string) => (this.description = newValue), onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue), onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue), onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection, onError: this.createFormError,
clearFormError: this.clearFormError, clearFormError: this.clearFormError,
}; };
@@ -140,10 +144,21 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.isExecuting = true; this.isExecuting = true;
this.triggerRender(); 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 { try {
if (!this.name || !this.description || !this.author) { startKey = traceStart(Action.NotebooksGalleryPublish, {
throw new Error("Name, description, and author are required"); databaseAccountName: this.container.databaseAccount()?.name,
} defaultExperience: this.container.defaultExperience(),
});
const response = await this.junoClient.publishNotebook( const response = await this.junoClient.publishNotebook(
this.name, this.name,
@@ -157,17 +172,43 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
const data = response.data; const data = response.data;
if (data) { if (data) {
let isPublishPending = false;
if (data.pendingScanJobIds?.length > 0) { if (data.pendingScanJobIds?.length > 0) {
isPublishPending = true;
NotificationConsoleUtils.logConsoleInfo( NotificationConsoleUtils.logConsoleInfo(
`Content of ${this.name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).` `Content of ${this.name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).`
); );
} else { } else {
NotificationConsoleUtils.logConsoleInfo(`Published ${this.name} to gallery`); NotificationConsoleUtils.logConsoleInfo(`Published ${this.name} to gallery`);
this.container.openGallery(GalleryTab.Published);
} }
traceSuccess(
Action.NotebooksGalleryPublish,
{
databaseAccountName: this.container.databaseAccount()?.name,
defaultExperience: this.container.defaultExperience(),
notebookId: data.id,
isPublishPending,
},
startKey
);
} }
} catch (error) { } catch (error) {
traceFailure(
Action.NotebooksGalleryPublish,
{
databaseAccountName: this.container.databaseAccount()?.name,
defaultExperience: this.container.defaultExperience(),
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
this.formError = `Failed to publish ${this.name} to gallery`; this.formError = `Failed to publish ${FileSystemUtil.stripExtension(this.name, "ipynb")} to gallery`;
this.formErrorDetail = `${errorMessage}`; this.formErrorDetail = `${errorMessage}`;
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", this.formError); handleError(errorMessage, "PublishNotebookPaneAdapter/submit", this.formError);
return; return;
@@ -180,7 +221,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.close(); this.close();
} }
private createFormErrorForLargeImageSelection = (formError: string, formErrorDetail: string, area: string): void => { private createFormError = (formError: string, formErrorDetail: string, area: string): void => {
this.formError = formError; this.formError = formError;
this.formErrorDetail = formErrorDetail; this.formErrorDetail = formErrorDetail;
handleError(formErrorDetail, area, formError); handleError(formErrorDetail, area, formError);

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos"; import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos";
import * as ko from "knockout"; import * as ko from "knockout";
import Q from "q";
import * as _ from "underscore"; import * as _ from "underscore";
import UploadWorker from "worker-loader!../../workers/upload"; import UploadWorker from "worker-loader!../../workers/upload";
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
@@ -27,7 +26,7 @@ import MongoQueryTab from "../Tabs/MongoQueryTab";
import MongoShellTab from "../Tabs/MongoShellTab"; import MongoShellTab from "../Tabs/MongoShellTab";
import QueryTab from "../Tabs/QueryTab"; import QueryTab from "../Tabs/QueryTab";
import QueryTablesTab from "../Tabs/QueryTablesTab"; import QueryTablesTab from "../Tabs/QueryTablesTab";
import SettingsTabV2 from "../Tabs/SettingsTabV2"; import { CollectionSettingsTabV2 } from "../Tabs/SettingsTabV2";
import ConflictId from "./ConflictId"; import ConflictId from "./ConflictId";
import DocumentId from "./DocumentId"; import DocumentId from "./DocumentId";
import StoredProcedure from "./StoredProcedure"; import StoredProcedure from "./StoredProcedure";
@@ -254,9 +253,9 @@ export default class Collection implements ViewModels.Collection {
}); });
} }
public expandCollection(): Q.Promise<any> { public expandCollection(): void {
if (this.isCollectionExpanded()) { if (this.isCollectionExpanded()) {
return Q(); return;
} }
this.isCollectionExpanded(true); this.isCollectionExpanded(true);
@@ -268,8 +267,6 @@ export default class Collection implements ViewModels.Collection {
defaultExperience: this.container.defaultExperience(), defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree, dataExplorerArea: Constants.Areas.ResourceTree,
}); });
return Q.resolve();
} }
public onDocumentDBDocumentsClick() { public onDocumentDBDocumentsClick() {
@@ -547,10 +544,12 @@ export default class Collection implements ViewModels.Collection {
}); });
const tabTitle = !this.offer() ? "Settings" : "Scale & Settings"; const tabTitle = !this.offer() ? "Settings" : "Scale & Settings";
const pendingNotificationsPromise: Q.Promise<DataModels.Notification> = this._getPendingThroughputSplitNotification(); const matchingTabs = this.container.tabsManager.getTabs(
const matchingTabs = this.container.tabsManager.getTabs(ViewModels.CollectionTabKind.SettingsV2, (tab) => { ViewModels.CollectionTabKind.CollectionSettingsV2,
return tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id(); (tab) => {
}); return tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id();
}
);
const traceStartData = { const traceStartData = {
databaseAccountName: this.container.databaseAccount().name, databaseAccountName: this.container.databaseAccount().name,
@@ -572,26 +571,20 @@ export default class Collection implements ViewModels.Collection {
onUpdateTabsButtons: this.container.onUpdateTabsButtons, onUpdateTabsButtons: this.container.onUpdateTabsButtons,
}; };
let settingsTabV2 = matchingTabs && (matchingTabs[0] as SettingsTabV2); let settingsTabV2 = matchingTabs && (matchingTabs[0] as CollectionSettingsTabV2);
this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions, pendingNotificationsPromise); this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions);
}; };
private launchSettingsTabV2 = ( private launchSettingsTabV2 = (
settingsTabV2: SettingsTabV2, settingsTabV2: CollectionSettingsTabV2,
traceStartData: any, traceStartData: any,
settingsTabOptions: ViewModels.TabOptions, settingsTabOptions: ViewModels.TabOptions
getPendingNotification: Q.Promise<DataModels.Notification>
): void => { ): void => {
const settingsTabV2Options: ViewModels.SettingsTabV2Options = {
...settingsTabOptions,
getPendingNotification: getPendingNotification,
};
if (!settingsTabV2) { if (!settingsTabV2) {
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, traceStartData); const startKey: number = TelemetryProcessor.traceStart(Action.Tab, traceStartData);
settingsTabV2Options.onLoadStartKey = startKey; settingsTabOptions.onLoadStartKey = startKey;
settingsTabV2Options.tabKind = ViewModels.CollectionTabKind.SettingsV2; settingsTabOptions.tabKind = ViewModels.CollectionTabKind.CollectionSettingsV2;
settingsTabV2 = new SettingsTabV2(settingsTabV2Options); settingsTabV2 = new CollectionSettingsTabV2(settingsTabOptions);
this.container.tabsManager.activateNewTab(settingsTabV2); this.container.tabsManager.activateNewTab(settingsTabV2);
} else { } else {
this.container.tabsManager.activateTab(settingsTabV2); this.container.tabsManager.activateTab(settingsTabV2);
@@ -976,23 +969,19 @@ export default class Collection implements ViewModels.Collection {
this.uploadFiles(event.originalEvent.dataTransfer.files); this.uploadFiles(event.originalEvent.dataTransfer.files);
} }
public onDeleteCollectionContextMenuClick(source: ViewModels.Collection, event: MouseEvent | KeyboardEvent) { public uploadFiles = (fileList: FileList): Promise<UploadDetails> => {
this.container.deleteCollectionConfirmationPane.open();
}
public uploadFiles = (fileList: FileList): Q.Promise<UploadDetails> => {
// TODO: right now web worker is not working with AAD flow. Use main thread for upload for now until we have backend upload capability // TODO: right now web worker is not working with AAD flow. Use main thread for upload for now until we have backend upload capability
if (configContext.platform === Platform.Hosted && window.authType === AuthType.AAD) { if (configContext.platform === Platform.Hosted && window.authType === AuthType.AAD) {
return this._uploadFilesCors(fileList); return this._uploadFilesCors(fileList);
} }
const documentUploader: Worker = new UploadWorker(); const documentUploader: Worker = new UploadWorker();
const deferred: Q.Deferred<UploadDetails> = Q.defer<UploadDetails>();
let inProgressNotificationId: string = ""; let inProgressNotificationId: string = "";
if (!fileList || fileList.length === 0) { if (!fileList || fileList.length === 0) {
return Q.reject("No files specified"); return Promise.reject("No files specified");
} }
documentUploader.onmessage = (event: MessageEvent) => {
const onmessage = (resolve: (value: UploadDetails) => void, reject: (reason: any) => void, event: MessageEvent) => {
const numSuccessful: number = event.data.numUploadsSuccessful; const numSuccessful: number = event.data.numUploadsSuccessful;
const numFailed: number = event.data.numUploadsFailed; const numFailed: number = event.data.numUploadsFailed;
const runtimeError: string = event.data.runtimeError; const runtimeError: string = event.data.runtimeError;
@@ -1001,31 +990,26 @@ export default class Collection implements ViewModels.Collection {
NotificationConsoleUtils.clearInProgressMessageWithId(inProgressNotificationId); NotificationConsoleUtils.clearInProgressMessageWithId(inProgressNotificationId);
documentUploader.terminate(); documentUploader.terminate();
if (!!runtimeError) { if (!!runtimeError) {
deferred.reject(runtimeError); reject(runtimeError);
} else if (numSuccessful === 0) { } else if (numSuccessful === 0) {
// all uploads failed // all uploads failed
NotificationConsoleUtils.logConsoleMessage( NotificationConsoleUtils.logConsoleError(`Failed to upload all documents to container ${this.id()}`);
ConsoleDataType.Error,
`Failed to upload all documents to container ${this.id()}`
);
} else if (numFailed > 0) { } else if (numFailed > 0) {
NotificationConsoleUtils.logConsoleMessage( NotificationConsoleUtils.logConsoleError(
ConsoleDataType.Error,
`Failed to upload ${numFailed} of ${numSuccessful + numFailed} documents to container ${this.id()}` `Failed to upload ${numFailed} of ${numSuccessful + numFailed} documents to container ${this.id()}`
); );
} else { } else {
NotificationConsoleUtils.logConsoleMessage( NotificationConsoleUtils.logConsoleInfo(
ConsoleDataType.Info,
`Successfully uploaded all ${numSuccessful} documents to container ${this.id()}` `Successfully uploaded all ${numSuccessful} documents to container ${this.id()}`
); );
} }
this._logUploadDetailsInConsole(uploadDetails); this._logUploadDetailsInConsole(uploadDetails);
deferred.resolve(uploadDetails); resolve(uploadDetails);
}; };
documentUploader.onerror = (event: ErrorEvent): void => { function onerror(reject: (reason: any) => void, event: ErrorEvent) {
documentUploader.terminate(); documentUploader.terminate();
deferred.reject(event.error); reject(event.error);
}; }
const uploaderMessage: StartUploadMessageParams = { const uploaderMessage: StartUploadMessageParams = {
files: fileList, files: fileList,
@@ -1040,42 +1024,68 @@ export default class Collection implements ViewModels.Collection {
}, },
}; };
documentUploader.postMessage(uploaderMessage); return new Promise<UploadDetails>((resolve, reject) => {
inProgressNotificationId = NotificationConsoleUtils.logConsoleMessage( documentUploader.onmessage = onmessage.bind(null, resolve, reject);
ConsoleDataType.InProgress, documentUploader.onerror = onerror.bind(null, reject);
`Uploading and creating documents in container ${this.id()}`
);
return deferred.promise; documentUploader.postMessage(uploaderMessage);
inProgressNotificationId = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Uploading and creating documents in container ${this.id()}`
);
});
}; };
private _uploadFilesCors(files: FileList): Q.Promise<UploadDetails> { public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
const deferred: Q.Deferred<UploadDetails> = Q.defer<UploadDetails>(); if (!this.container) {
const promises: Array<Q.Promise<UploadDetailsRecord>> = []; return undefined;
for (let i = 0; i < files.length; i++) {
promises.push(this._uploadFile(files[i]));
} }
Q.all(promises).then((uploadDetails: Array<UploadDetailsRecord>) => {
deferred.resolve({ data: uploadDetails });
});
return deferred.promise; 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 _uploadFile(file: File): Q.Promise<UploadDetailsRecord> { private async _uploadFilesCors(files: FileList): Promise<UploadDetails> {
const deferred: Q.Deferred<UploadDetailsRecord> = Q.defer(); const data = await Promise.all(Array.from(files).map((file) => this._uploadFile(file)));
return { data };
}
private _uploadFile(file: File): Promise<UploadDetailsRecord> {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (evt: any): void => { const onload = (resolve: (value: UploadDetailsRecord) => void, evt: any): void => {
const fileData: string = evt.target.result; const fileData: string = evt.target.result;
this._createDocumentsFromFile(file.name, fileData).then((record) => { this._createDocumentsFromFile(file.name, fileData).then((record) => resolve(record));
deferred.resolve(record);
});
}; };
reader.onerror = (evt: ProgressEvent): void => { const onerror = (resolve: (value: UploadDetailsRecord) => void, evt: ProgressEvent): void => {
deferred.resolve({ resolve({
fileName: file.name, fileName: file.name,
numSucceeded: 0, numSucceeded: 0,
numFailed: 1, numFailed: 1,
@@ -1083,9 +1093,11 @@ export default class Collection implements ViewModels.Collection {
}); });
}; };
reader.readAsText(file); return new Promise<UploadDetailsRecord>((resolve) => {
reader.onload = onload.bind(this, resolve);
return deferred.promise; reader.onerror = onerror.bind(this, resolve);
reader.readAsText(file);
});
} }
private async _createDocumentsFromFile(fileName: string, documentContent: string): Promise<UploadDetailsRecord> { private async _createDocumentsFromFile(fileName: string, documentContent: string): Promise<UploadDetailsRecord> {
@@ -1119,48 +1131,6 @@ export default class Collection implements ViewModels.Collection {
} }
} }
private _getPendingThroughputSplitNotification(): Q.Promise<DataModels.Notification> {
if (!this.container) {
return Q.resolve(undefined);
}
const deferred: Q.Deferred<DataModels.Notification> = Q.defer<DataModels.Notification>();
fetchPortalNotifications().then(
(notifications: DataModels.Notification[]) => {
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 === 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.databaseId,
collectionName: this.id(),
}),
"Settings tree node"
);
deferred.resolve(undefined);
}
);
return deferred.promise;
}
private _logUploadDetailsInConsole(uploadDetails: UploadDetails): void { private _logUploadDetailsInConsole(uploadDetails: UploadDetails): void {
const uploadDetailsRecords: UploadDetailsRecord[] = uploadDetails.data; const uploadDetailsRecords: UploadDetailsRecord[] = uploadDetails.data;
const numFiles: number = uploadDetailsRecords.length; const numFiles: number = uploadDetailsRecords.length;

View File

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

View File

@@ -41,9 +41,9 @@ export default class ResourceTokenCollection implements ViewModels.CollectionBas
this.isCollectionExpanded = ko.observable<boolean>(true); this.isCollectionExpanded = ko.observable<boolean>(true);
} }
public expandCollection(): Q.Promise<void> { public expandCollection(): void {
if (this.isCollectionExpanded()) { if (this.isCollectionExpanded()) {
return Q(); return;
} }
this.isCollectionExpanded(true); this.isCollectionExpanded(true);
@@ -55,8 +55,6 @@ export default class ResourceTokenCollection implements ViewModels.CollectionBas
defaultExperience: this.container.defaultExperience(), defaultExperience: this.container.defaultExperience(),
dataExplorerArea: Constants.Areas.ResourceTree, dataExplorerArea: Constants.Areas.ResourceTree,
}); });
return Q.resolve();
} }
public collapseCollection() { public collapseCollection() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
{
"translations": {
"Common": {
"Save": "Save",
"Discard": "Discard",
"Refresh": "Refesh"
},
"SelfServeExample": {
"North Central US": "North Central US",
"West US": "West US",
"East US 2": "East US 2",
"ClassInfo": "This is a self serve class",
"RegionDropdownInfo": "More regions can be added in the future.",
"ValidationError": "Regions and AccountName should not be empty.",
"DescriptionText": "This class sets collection and database throughput.",
"DecriptionLinkText": "Click here for more information",
"Regions": "Regions",
"RegionsPlaceholder": "Select a region",
"Enable Logging": "Enable Logging",
"Enable": "Enable",
"Disable": "Disable",
"Account Name": "Account Name",
"AccountNamePlaceHolder": "Enter the account name",
"Collection Throughput": "Collection Throughput",
"Enable DB level throughput": "Enable DB level throughput",
"Database Throughput": "Database Throughput",
"RefreshMessage": "Self Serve Example successfully refreshing",
"SubmissionMessage": "Submitted successfully"
},
"SqlX": {
"DedicatedGatewayDescription": "Provisioning dedicated gateways for SqlX accounts.",
"DedicatedGateway": "Dedicated Gateway",
"Enable": "Enable",
"Disable": "Disable",
"LearnAboutDedicatedGateway": "Learn more about dedicated gateway.",
"SKUs": "SKUs",
"NumberOfInstances": "Number of instances",
"CosmosD4s": "Cosmos.D4s",
"CosmosD8s": "Cosmos.D8s",
"CosmosD16s": "Cosmos.D16s",
"CosmosD32s": "Cosmos.D32s",
"CreateMessage": "DedicatedGateway resource is being created.",
"UpdateMessage": "DedicatedGateway resource is being updated.",
"DeleteMessage": "DedicatedGateway resource is being deleted.",
"CannotSave": "Cannot save the changes to the DedicatedGateway resource at the moment",
"DedicatedGatewayEndpoint": "DedicatedGatewayEndpoint",
"NoValue": ""
}
}
}

View File

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

View File

@@ -288,11 +288,9 @@ export class TabRouteHandler {
private _openSprocTabForResource(databaseId: string, collectionId: string, sprocId: string): void { private _openSprocTabForResource(databaseId: string, collectionId: string, sprocId: string): void {
this._executeActionHelper(() => { this._executeActionHelper(() => {
const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId); const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId);
collection && collection && collection.expandCollection();
collection.expandCollection().then(() => { const storedProcedure = collection && collection.findStoredProcedureWithId(sprocId);
const storedProcedure = collection && collection.findStoredProcedureWithId(sprocId); storedProcedure && storedProcedure.open();
storedProcedure && storedProcedure.open();
});
}); });
} }
@@ -319,11 +317,9 @@ export class TabRouteHandler {
private _openTriggerTabForResource(databaseId: string, collectionId: string, triggerId: string): void { private _openTriggerTabForResource(databaseId: string, collectionId: string, triggerId: string): void {
this._executeActionHelper(() => { this._executeActionHelper(() => {
const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId); const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId);
collection && collection && collection.expandCollection();
collection.expandCollection().then(() => { const trigger = collection && collection.findTriggerWithId(triggerId);
const trigger = collection && collection.findTriggerWithId(triggerId); trigger && trigger.open();
trigger && trigger.open();
});
}); });
} }
@@ -350,11 +346,9 @@ export class TabRouteHandler {
private _openUserDefinedFunctionTabForResource(databaseId: string, collectionId: string, udfId: string): void { private _openUserDefinedFunctionTabForResource(databaseId: string, collectionId: string, udfId: string): void {
this._executeActionHelper(() => { this._executeActionHelper(() => {
const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId); const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId);
collection && collection && collection.expandCollection();
collection.expandCollection().then(() => { const userDefinedFunction = collection && collection.findUserDefinedFunctionWithId(udfId);
const userDefinedFunction = collection && collection.findUserDefinedFunctionWithId(udfId); userDefinedFunction && userDefinedFunction.open();
userDefinedFunction && userDefinedFunction.open();
});
}); });
} }

View File

@@ -8,7 +8,7 @@ interface Decorator {
} }
interface InputOptionsBase { interface InputOptionsBase {
label: string; labelTKey: string;
} }
export interface NumberInputOptions extends InputOptionsBase { export interface NumberInputOptions extends InputOptionsBase {
@@ -19,17 +19,17 @@ export interface NumberInputOptions extends InputOptionsBase {
} }
export interface StringInputOptions extends InputOptionsBase { export interface StringInputOptions extends InputOptionsBase {
placeholder?: (() => Promise<string>) | string; placeholderTKey?: (() => Promise<string>) | string;
} }
export interface BooleanInputOptions extends InputOptionsBase { export interface BooleanInputOptions extends InputOptionsBase {
trueLabel: (() => Promise<string>) | string; trueLabelTKey: (() => Promise<string>) | string;
falseLabel: (() => Promise<string>) | string; falseLabelTKey: (() => Promise<string>) | string;
} }
export interface ChoiceInputOptions extends InputOptionsBase { export interface ChoiceInputOptions extends InputOptionsBase {
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[]; choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
placeholder?: (() => Promise<string>) | string; placeholderTKey?: (() => Promise<string>) | string;
} }
export interface DescriptionDisplayOptions { export interface DescriptionDisplayOptions {
@@ -48,7 +48,7 @@ const isNumberInputOptions = (inputOptions: InputOptions): inputOptions is Numbe
}; };
const isBooleanInputOptions = (inputOptions: InputOptions): inputOptions is BooleanInputOptions => { const isBooleanInputOptions = (inputOptions: InputOptions): inputOptions is BooleanInputOptions => {
return "trueLabel" in inputOptions; return "trueLabelTKey" in inputOptions;
}; };
const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is ChoiceInputOptions => { const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is ChoiceInputOptions => {
@@ -92,7 +92,7 @@ export const PropertyInfo = (info: (() => Promise<Info>) | Info): PropertyDecora
export const Values = (inputOptions: InputOptions): PropertyDecorator => { export const Values = (inputOptions: InputOptions): PropertyDecorator => {
if (isNumberInputOptions(inputOptions)) { if (isNumberInputOptions(inputOptions)) {
return addToMap( return addToMap(
{ name: "label", value: inputOptions.label }, { name: "labelTKey", value: inputOptions.labelTKey },
{ name: "min", value: inputOptions.min }, { name: "min", value: inputOptions.min },
{ name: "max", value: inputOptions.max }, { name: "max", value: inputOptions.max },
{ name: "step", value: inputOptions.step }, { name: "step", value: inputOptions.step },
@@ -100,22 +100,22 @@ export const Values = (inputOptions: InputOptions): PropertyDecorator => {
); );
} else if (isBooleanInputOptions(inputOptions)) { } else if (isBooleanInputOptions(inputOptions)) {
return addToMap( return addToMap(
{ name: "label", value: inputOptions.label }, { name: "labelTKey", value: inputOptions.labelTKey },
{ name: "trueLabel", value: inputOptions.trueLabel }, { name: "trueLabelTKey", value: inputOptions.trueLabelTKey },
{ name: "falseLabel", value: inputOptions.falseLabel } { name: "falseLabelTKey", value: inputOptions.falseLabelTKey }
); );
} else if (isChoiceInputOptions(inputOptions)) { } else if (isChoiceInputOptions(inputOptions)) {
return addToMap( return addToMap(
{ name: "label", value: inputOptions.label }, { name: "labelTKey", value: inputOptions.labelTKey },
{ name: "placeholder", value: inputOptions.placeholder }, { name: "placeholderTKey", value: inputOptions.placeholderTKey },
{ name: "choices", value: inputOptions.choices } { name: "choices", value: inputOptions.choices }
); );
} else if (isDescriptionDisplayOptions(inputOptions)) { } else if (isDescriptionDisplayOptions(inputOptions)) {
return addToMap({ name: "description", value: inputOptions.description }); return addToMap({ name: "description", value: inputOptions.description });
} else { } else {
return addToMap( return addToMap(
{ name: "label", value: inputOptions.label }, { name: "labelTKey", value: inputOptions.labelTKey },
{ name: "placeholder", value: inputOptions.placeholder } { name: "placeholderTKey", value: inputOptions.placeholderTKey }
); );
} }
}; };

View File

@@ -16,10 +16,22 @@ export interface InitializeResponse {
dbThroughput: number; dbThroughput: number;
} }
export const getMaxThroughput = async (): Promise<number> => { export const getMaxCollectionThroughput = async (): Promise<number> => {
return 10000; return 10000;
}; };
export const getMinCollectionThroughput = async (): Promise<number> => {
return 400;
};
export const getMaxDatabaseThroughput = async (): Promise<number> => {
return 10000;
};
export const getMinDatabaseThroughput = async (): Promise<number> => {
return 400;
};
export const update = async ( export const update = async (
regions: Regions, regions: Regions,
enableLogging: boolean, enableLogging: boolean,
@@ -59,6 +71,6 @@ export const onRefreshSelfServeExample = async (): Promise<RefreshResult> => {
const isUpdateInProgress = databaseAccountGetResults.properties.provisioningState !== "Succeeded"; const isUpdateInProgress = databaseAccountGetResults.properties.provisioningState !== "Succeeded";
return { return {
isUpdateInProgress: isUpdateInProgress, isUpdateInProgress: isUpdateInProgress,
notificationMessage: "Self Serve Example successfully refreshing", notificationMessage: "RefreshMessage",
}; };
}; };

View File

@@ -10,7 +10,16 @@ import {
SelfServeNotificationType, SelfServeNotificationType,
SmartUiInput, SmartUiInput,
} from "../SelfServeTypes"; } from "../SelfServeTypes";
import { onRefreshSelfServeExample, getMaxThroughput, Regions, update, initialize } from "./SelfServeExample.rp"; import {
onRefreshSelfServeExample,
Regions,
update,
initialize,
getMinDatabaseThroughput,
getMaxDatabaseThroughput,
getMinCollectionThroughput,
getMaxCollectionThroughput,
} from "./SelfServeExample.rp";
const regionDropdownItems: ChoiceItem[] = [ const regionDropdownItems: ChoiceItem[] = [
{ label: "North Central US", key: Regions.NorthCentralUS }, { label: "North Central US", key: Regions.NorthCentralUS },
@@ -19,11 +28,11 @@ const regionDropdownItems: ChoiceItem[] = [
]; ];
const selfServeExampleInfo: Info = { const selfServeExampleInfo: Info = {
message: "This is a self serve class", messageTKey: "ClassInfo",
}; };
const regionDropdownInfo: Info = { const regionDropdownInfo: Info = {
message: "More regions can be added in the future.", messageTKey: "RegionDropdownInfo",
}; };
const onRegionsChange = (currentState: Map<string, SmartUiInput>, newValue: InputType): Map<string, SmartUiInput> => { const onRegionsChange = (currentState: Map<string, SmartUiInput>, newValue: InputType): Map<string, SmartUiInput> => {
@@ -50,7 +59,7 @@ const onEnableDbLevelThroughputChange = (
const validate = (currentvalues: Map<string, SmartUiInput>): void => { const validate = (currentvalues: Map<string, SmartUiInput>): void => {
if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) { if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) {
throw new Error("Regions and AccountName should not be empty."); throw new Error("ValidationError");
} }
}; };
@@ -66,6 +75,9 @@ const validate = (currentvalues: Map<string, SmartUiInput>): void => {
You can test this self serve UI by using the featureflag '?feature.selfServeType=example' You can test this self serve UI by using the featureflag '?feature.selfServeType=example'
and plumb in similar feature flags for your own self serve class. and plumb in similar feature flags for your own self serve class.
All string to be used should be present in the "src/Localization" folder, in the language specific json files. The
corresponding key should be given as the value for the fields like "label", the error message etc.
*/ */
/* /*
@@ -117,7 +129,7 @@ export default class SelfServeExample extends SelfServeBaseClass {
let dbThroughput = currentValues.get("dbThroughput")?.value as number; let dbThroughput = currentValues.get("dbThroughput")?.value as number;
dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined; dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined;
await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput); await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput);
return { message: "submitted successfully", type: SelfServeNotificationType.info }; return { message: "SubmissionMessage", type: SelfServeNotificationType.info };
}; };
/* /*
@@ -161,10 +173,10 @@ export default class SelfServeExample extends SelfServeBaseClass {
*/ */
@Values({ @Values({
description: { description: {
text: "This class sets collection and database throughput.", textTKey: "DescriptionText",
link: { link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
text: "Click here for more information", textTKey: "DecriptionLinkText",
}, },
}, },
}) })
@@ -193,26 +205,26 @@ export default class SelfServeExample extends SelfServeBaseClass {
any other value of "regions" any other value of "regions"
*/ */
@OnChange(onRegionsChange) @OnChange(onRegionsChange)
@Values({ label: "Regions", choices: regionDropdownItems, placeholder: "Select a region" }) @Values({ labelTKey: "Regions", choices: regionDropdownItems, placeholderTKey: "RegionsPlaceholder" })
regions: ChoiceItem; regions: ChoiceItem;
@Values({ @Values({
label: "Enable Logging", labelTKey: "Enable Logging",
trueLabel: "Enable", trueLabelTKey: "Enable",
falseLabel: "Disable", falseLabelTKey: "Disable",
}) })
enableLogging: boolean; enableLogging: boolean;
@Values({ @Values({
label: "Account Name", labelTKey: "Account Name",
placeholder: "Enter the account name", placeholderTKey: "AccountNamePlaceHolder",
}) })
accountName: string; accountName: string;
@Values({ @Values({
label: "Collection Throughput", labelTKey: "Collection Throughput",
min: 400, min: getMinCollectionThroughput,
max: getMaxThroughput, max: getMaxCollectionThroughput,
step: 100, step: 100,
uiType: NumberUiType.Spinner, uiType: NumberUiType.Spinner,
}) })
@@ -224,16 +236,16 @@ export default class SelfServeExample extends SelfServeBaseClass {
*/ */
@OnChange(onEnableDbLevelThroughputChange) @OnChange(onEnableDbLevelThroughputChange)
@Values({ @Values({
label: "Enable DB level throughput", labelTKey: "Enable DB level throughput",
trueLabel: "Enable", trueLabelTKey: "Enable",
falseLabel: "Disable", falseLabelTKey: "Disable",
}) })
enableDbLevelThroughput: boolean; enableDbLevelThroughput: boolean;
@Values({ @Values({
label: "Database Throughput", labelTKey: "Database Throughput",
min: 400, min: getMinDatabaseThroughput,
max: getMaxThroughput, max: getMaxDatabaseThroughput,
step: 100, step: 100,
uiType: NumberUiType.Slider, uiType: NumberUiType.Slider,
}) })

View File

@@ -34,17 +34,17 @@ describe("SelfServeComponent", () => {
root: { root: {
id: "root", id: "root",
info: { info: {
message: "Start at $24/mo per database", messageTKey: "Start at $24/mo per database",
link: { link: {
href: "https://aka.ms/azure-cosmos-db-pricing", href: "https://aka.ms/azure-cosmos-db-pricing",
text: "More Details", textTKey: "More Details",
}, },
}, },
children: [ children: [
{ {
id: "throughput", id: "throughput",
input: { input: {
label: "Throughput (input)", labelTKey: "Throughput (input)",
dataFieldName: "throughput", dataFieldName: "throughput",
type: "number", type: "number",
min: 400, min: 400,
@@ -57,7 +57,7 @@ describe("SelfServeComponent", () => {
{ {
id: "containerId", id: "containerId",
input: { input: {
label: "Container id", labelTKey: "Container id",
dataFieldName: "containerId", dataFieldName: "containerId",
type: "string", type: "string",
}, },
@@ -65,9 +65,9 @@ describe("SelfServeComponent", () => {
{ {
id: "analyticalStore", id: "analyticalStore",
input: { input: {
label: "Analytical Store", labelTKey: "Analytical Store",
trueLabel: "Enabled", trueLabelTKey: "Enabled",
falseLabel: "Disabled", falseLabelTKey: "Disabled",
defaultValue: true, defaultValue: true,
dataFieldName: "analyticalStore", dataFieldName: "analyticalStore",
type: "boolean", type: "boolean",
@@ -76,7 +76,7 @@ describe("SelfServeComponent", () => {
{ {
id: "database", id: "database",
input: { input: {
label: "Database", labelTKey: "Database",
dataFieldName: "database", dataFieldName: "database",
type: "object", type: "object",
choices: [ choices: [

View File

@@ -26,6 +26,9 @@ import {
} from "./SelfServeTypes"; } from "./SelfServeTypes";
import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent"; import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import { getMessageBarType } from "./SelfServeUtils"; import { getMessageBarType } from "./SelfServeUtils";
import { Translation } from "react-i18next";
import { TFunction } from "i18next";
import "../i18n";
export interface SelfServeComponentProps { export interface SelfServeComponentProps {
descriptor: SelfServeDescriptor; descriptor: SelfServeDescriptor;
@@ -43,6 +46,8 @@ export interface SelfServeComponentState {
} }
export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> { export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> {
private smartUiGeneratorClassName: string;
componentDidMount(): void { componentDidMount(): void {
this.performRefresh(); this.performRefresh();
this.initializeSmartUiComponent(); this.initializeSmartUiComponent();
@@ -60,6 +65,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
notification: undefined, notification: undefined,
refreshResult: undefined, refreshResult: undefined,
}; };
this.smartUiGeneratorClassName = this.props.descriptor.root.id;
} }
private onError = (hasErrors: boolean): void => { private onError = (hasErrors: boolean): void => {
@@ -147,8 +153,8 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
currentValues: Map<string, SmartUiInput>, currentValues: Map<string, SmartUiInput>,
baselineValues: Map<string, SmartUiInput> baselineValues: Map<string, SmartUiInput>
): Promise<AnyDisplay> => { ): Promise<AnyDisplay> => {
input.label = await this.getResolvedValue(input.label); input.labelTKey = await this.getResolvedValue(input.labelTKey);
input.placeholder = await this.getResolvedValue(input.placeholder); input.placeholderTKey = await this.getResolvedValue(input.placeholderTKey);
switch (input.type) { switch (input.type) {
case "string": { case "string": {
@@ -177,8 +183,8 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
} }
case "boolean": { case "boolean": {
const booleanInput = input as BooleanInput; const booleanInput = input as BooleanInput;
booleanInput.trueLabel = await this.getResolvedValue(booleanInput.trueLabel); booleanInput.trueLabelTKey = await this.getResolvedValue(booleanInput.trueLabelTKey);
booleanInput.falseLabel = await this.getResolvedValue(booleanInput.falseLabel); booleanInput.falseLabelTKey = await this.getResolvedValue(booleanInput.falseLabelTKey);
return booleanInput; return booleanInput;
} }
default: { default: {
@@ -214,7 +220,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
onSavePromise.catch((error) => { onSavePromise.catch((error) => {
this.setState({ this.setState({
notification: { notification: {
message: `Error: ${error.message}`, message: `${error.message}`,
type: SelfServeNotificationType.error, type: SelfServeNotificationType.error,
}, },
}); });
@@ -273,11 +279,15 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
this.setState({ isInitializing: false }); this.setState({ isInitializing: false });
}; };
private getCommandBarItems = (): ICommandBarItemProps[] => { public getCommonTranslation = (translationFunction: TFunction, key: string): string => {
return translationFunction(`Common.${key}`);
};
private getCommandBarItems = (translate: TFunction): ICommandBarItemProps[] => {
return [ return [
{ {
key: "save", key: "save",
text: "Save", text: this.getCommonTranslation(translate, "Save"),
iconProps: { iconName: "Save" }, iconProps: { iconName: "Save" },
split: true, split: true,
disabled: this.isSaveButtonDisabled(), disabled: this.isSaveButtonDisabled(),
@@ -285,7 +295,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
}, },
{ {
key: "discard", key: "discard",
text: "Discard", text: this.getCommonTranslation(translate, "Discard"),
iconProps: { iconName: "Undo" }, iconProps: { iconName: "Undo" },
split: true, split: true,
disabled: this.isDiscardButtonDisabled(), disabled: this.isDiscardButtonDisabled(),
@@ -295,7 +305,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
}, },
{ {
key: "refresh", key: "refresh",
text: "Refresh", text: this.getCommonTranslation(translate, "Refresh"),
disabled: this.state.isInitializing, disabled: this.state.isInitializing,
iconProps: { iconName: "Refresh" }, iconProps: { iconName: "Refresh" },
split: true, split: true,
@@ -306,47 +316,66 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
]; ];
}; };
private getNotificationMessageTranslation = (translationFunction: TFunction, messageKey: string): string => {
const translation = translationFunction(messageKey);
if (translation === `${this.smartUiGeneratorClassName}.${messageKey}`) {
return messageKey;
}
return translation;
};
public render(): JSX.Element { public render(): JSX.Element {
const containerStackTokens: IStackTokens = { childrenGap: 5 }; const containerStackTokens: IStackTokens = { childrenGap: 5 };
if (this.state.compileErrorMessage) { if (this.state.compileErrorMessage) {
return <MessageBar messageBarType={MessageBarType.error}>{this.state.compileErrorMessage}</MessageBar>; return <MessageBar messageBarType={MessageBarType.error}>{this.state.compileErrorMessage}</MessageBar>;
} }
return ( return (
<div style={{ overflowX: "auto" }}> <Translation>
<Stack tokens={containerStackTokens} styles={{ root: { padding: 10 } }}> {(translate) => {
<CommandBar styles={{ root: { paddingLeft: 0 } }} items={this.getCommandBarItems()} /> const getTranslation = (key: string): string => {
{this.state.isInitializing ? ( return translate(`${this.smartUiGeneratorClassName}.${key}`);
<Spinner };
size={SpinnerSize.large}
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }} return (
/> <div style={{ overflowX: "auto" }}>
) : ( <Stack tokens={containerStackTokens} styles={{ root: { padding: 10 } }}>
<> <CommandBar styles={{ root: { paddingLeft: 0 } }} items={this.getCommandBarItems(translate)} />
{this.state.refreshResult?.isUpdateInProgress && ( {this.state.isInitializing ? (
<MessageBar messageBarType={MessageBarType.info} styles={{ root: { width: 400 } }}> <Spinner
{this.state.refreshResult.notificationMessage} size={SpinnerSize.large}
</MessageBar> styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
)} />
{this.state.notification && ( ) : (
<MessageBar <>
messageBarType={getMessageBarType(this.state.notification.type)} {this.state.refreshResult?.isUpdateInProgress && (
styles={{ root: { width: 400 } }} <MessageBar messageBarType={MessageBarType.info} styles={{ root: { width: 400 } }}>
onDismiss={() => this.setState({ notification: undefined })} {getTranslation(this.state.refreshResult.notificationMessage)}
> </MessageBar>
{this.state.notification.message} )}
</MessageBar> {this.state.notification && (
)} <MessageBar
<SmartUiComponent messageBarType={getMessageBarType(this.state.notification.type)}
disabled={this.state.refreshResult?.isUpdateInProgress} styles={{ root: { width: 400 } }}
descriptor={this.state.root as SmartUiDescriptor} onDismiss={() => this.setState({ notification: undefined })}
currentValues={this.state.currentValues} >
onInputChange={this.onInputChange} {this.getNotificationMessageTranslation(getTranslation, this.state.notification.message)}
onError={this.onError} </MessageBar>
/> )}
</> <SmartUiComponent
)} disabled={this.state.refreshResult?.isUpdateInProgress}
</Stack> descriptor={this.state.root as SmartUiDescriptor}
</div> currentValues={this.state.currentValues}
onInputChange={this.onInputChange}
onError={this.onError}
getTranslation={getTranslation}
/>
</>
)}
</Stack>
</div>
);
}}
</Translation>
); );
} }
} }

View File

@@ -2,9 +2,9 @@ interface BaseInput {
dataFieldName: string; dataFieldName: string;
errorMessage?: string; errorMessage?: string;
type: InputTypeValue; type: InputTypeValue;
label?: (() => Promise<string>) | string; labelTKey?: (() => Promise<string>) | string;
onChange?: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>; onChange?: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>;
placeholder?: (() => Promise<string>) | string; placeholderTKey?: (() => Promise<string>) | string;
} }
export interface NumberInput extends BaseInput { export interface NumberInput extends BaseInput {
@@ -16,8 +16,8 @@ export interface NumberInput extends BaseInput {
} }
export interface BooleanInput extends BaseInput { export interface BooleanInput extends BaseInput {
trueLabel: (() => Promise<string>) | string; trueLabelTKey: (() => Promise<string>) | string;
falseLabel: (() => Promise<string>) | string; falseLabelTKey: (() => Promise<string>) | string;
defaultValue?: boolean; defaultValue?: boolean;
} }
@@ -92,18 +92,18 @@ export type ChoiceItem = { label: string; key: string };
export type InputType = number | string | boolean | ChoiceItem; export type InputType = number | string | boolean | ChoiceItem;
export interface Info { export interface Info {
message: string; messageTKey: string;
link?: { link?: {
href: string; href: string;
text: string; textTKey: string;
}; };
} }
export interface Description { export interface Description {
text: string; textTKey: string;
link?: { link?: {
href: string; href: string;
text: string; textTKey: string;
}; };
} }

View File

@@ -58,7 +58,7 @@ describe("SelfServeUtils", () => {
id: "dbThroughput", id: "dbThroughput",
dataFieldName: "dbThroughput", dataFieldName: "dbThroughput",
type: "number", type: "number",
label: "Database Throughput", labelTKey: "Database Throughput",
min: 1, min: 1,
max: 5, max: 5,
step: 1, step: 1,
@@ -71,7 +71,7 @@ describe("SelfServeUtils", () => {
id: "collThroughput", id: "collThroughput",
dataFieldName: "collThroughput", dataFieldName: "collThroughput",
type: "number", type: "number",
label: "Coll Throughput", labelTKey: "Coll Throughput",
min: 1, min: 1,
max: 5, max: 5,
step: 1, step: 1,
@@ -84,7 +84,7 @@ describe("SelfServeUtils", () => {
id: "invalidThroughput", id: "invalidThroughput",
dataFieldName: "invalidThroughput", dataFieldName: "invalidThroughput",
type: "boolean", type: "boolean",
label: "Invalid Coll Throughput", labelTKey: "Invalid Coll Throughput",
min: 1, min: 1,
max: 5, max: 5,
step: 1, step: 1,
@@ -98,8 +98,8 @@ describe("SelfServeUtils", () => {
id: "collName", id: "collName",
dataFieldName: "collName", dataFieldName: "collName",
type: "string", type: "string",
label: "Coll Name", labelTKey: "Coll Name",
placeholder: "placeholder text", placeholderTKey: "placeholder text",
}, },
], ],
[ [
@@ -108,9 +108,9 @@ describe("SelfServeUtils", () => {
id: "enableLogging", id: "enableLogging",
dataFieldName: "enableLogging", dataFieldName: "enableLogging",
type: "boolean", type: "boolean",
label: "Enable Logging", labelTKey: "Enable Logging",
trueLabel: "Enable", trueLabelTKey: "Enable",
falseLabel: "Disable", falseLabelTKey: "Disable",
}, },
], ],
[ [
@@ -119,8 +119,8 @@ describe("SelfServeUtils", () => {
id: "invalidEnableLogging", id: "invalidEnableLogging",
dataFieldName: "invalidEnableLogging", dataFieldName: "invalidEnableLogging",
type: "boolean", type: "boolean",
label: "Invalid Enable Logging", labelTKey: "Invalid Enable Logging",
placeholder: "placeholder text", placeholderTKey: "placeholder text",
}, },
], ],
[ [
@@ -129,7 +129,7 @@ describe("SelfServeUtils", () => {
id: "regions", id: "regions",
dataFieldName: "regions", dataFieldName: "regions",
type: "object", type: "object",
label: "Regions", labelTKey: "Regions",
choices: [ choices: [
{ label: "South West US", key: "SWUS" }, { label: "South West US", key: "SWUS" },
{ label: "North Central US", key: "NCUS" }, { label: "North Central US", key: "NCUS" },
@@ -143,14 +143,14 @@ describe("SelfServeUtils", () => {
id: "invalidRegions", id: "invalidRegions",
dataFieldName: "invalidRegions", dataFieldName: "invalidRegions",
type: "object", type: "object",
label: "Invalid Regions", labelTKey: "Invalid Regions",
placeholder: "placeholder text", placeholderTKey: "placeholder text",
}, },
], ],
]); ]);
const expectedDescriptor = { const expectedDescriptor = {
root: { root: {
id: "root", id: "TestClass",
children: [ children: [
{ {
id: "dbThroughput", id: "dbThroughput",
@@ -158,7 +158,7 @@ describe("SelfServeUtils", () => {
id: "dbThroughput", id: "dbThroughput",
dataFieldName: "dbThroughput", dataFieldName: "dbThroughput",
type: "number", type: "number",
label: "Database Throughput", labelTKey: "Database Throughput",
min: 1, min: 1,
max: 5, max: 5,
step: 1, step: 1,
@@ -172,7 +172,7 @@ describe("SelfServeUtils", () => {
id: "collThroughput", id: "collThroughput",
dataFieldName: "collThroughput", dataFieldName: "collThroughput",
type: "number", type: "number",
label: "Coll Throughput", labelTKey: "Coll Throughput",
min: 1, min: 1,
max: 5, max: 5,
step: 1, step: 1,
@@ -186,7 +186,7 @@ describe("SelfServeUtils", () => {
id: "invalidThroughput", id: "invalidThroughput",
dataFieldName: "invalidThroughput", dataFieldName: "invalidThroughput",
type: "boolean", type: "boolean",
label: "Invalid Coll Throughput", labelTKey: "Invalid Coll Throughput",
min: 1, min: 1,
max: 5, max: 5,
step: 1, step: 1,
@@ -201,8 +201,8 @@ describe("SelfServeUtils", () => {
id: "collName", id: "collName",
dataFieldName: "collName", dataFieldName: "collName",
type: "string", type: "string",
label: "Coll Name", labelTKey: "Coll Name",
placeholder: "placeholder text", placeholderTKey: "placeholder text",
}, },
children: [] as Node[], children: [] as Node[],
}, },
@@ -212,9 +212,9 @@ describe("SelfServeUtils", () => {
id: "enableLogging", id: "enableLogging",
dataFieldName: "enableLogging", dataFieldName: "enableLogging",
type: "boolean", type: "boolean",
label: "Enable Logging", labelTKey: "Enable Logging",
trueLabel: "Enable", trueLabelTKey: "Enable",
falseLabel: "Disable", falseLabelTKey: "Disable",
}, },
children: [] as Node[], children: [] as Node[],
}, },
@@ -224,8 +224,8 @@ describe("SelfServeUtils", () => {
id: "invalidEnableLogging", id: "invalidEnableLogging",
dataFieldName: "invalidEnableLogging", dataFieldName: "invalidEnableLogging",
type: "boolean", type: "boolean",
label: "Invalid Enable Logging", labelTKey: "Invalid Enable Logging",
placeholder: "placeholder text", placeholderTKey: "placeholder text",
errorMessage: "label, truelabel and falselabel are required for boolean input 'invalidEnableLogging'.", errorMessage: "label, truelabel and falselabel are required for boolean input 'invalidEnableLogging'.",
}, },
children: [] as Node[], children: [] as Node[],
@@ -236,7 +236,7 @@ describe("SelfServeUtils", () => {
id: "regions", id: "regions",
dataFieldName: "regions", dataFieldName: "regions",
type: "object", type: "object",
label: "Regions", labelTKey: "Regions",
choices: [ choices: [
{ label: "South West US", key: "SWUS" }, { label: "South West US", key: "SWUS" },
{ label: "North Central US", key: "NCUS" }, { label: "North Central US", key: "NCUS" },
@@ -251,8 +251,8 @@ describe("SelfServeUtils", () => {
id: "invalidRegions", id: "invalidRegions",
dataFieldName: "invalidRegions", dataFieldName: "invalidRegions",
type: "object", type: "object",
label: "Invalid Regions", labelTKey: "Invalid Regions",
placeholder: "placeholder text", placeholderTKey: "placeholder text",
errorMessage: "label and choices are required for Choice input 'invalidRegions'.", errorMessage: "label and choices are required for Choice input 'invalidRegions'.",
}, },
children: [] as Node[], children: [] as Node[],
@@ -270,7 +270,7 @@ describe("SelfServeUtils", () => {
"invalidRegions", "invalidRegions",
], ],
}; };
const descriptor = mapToSmartUiDescriptor(context); const descriptor = mapToSmartUiDescriptor("TestClass", context);
expect(descriptor).toEqual(expectedDescriptor); expect(descriptor).toEqual(expectedDescriptor);
}); });
}); });

View File

@@ -32,14 +32,14 @@ export interface DecoratorProperties {
id: string; id: string;
info?: (() => Promise<Info>) | Info; info?: (() => Promise<Info>) | Info;
type?: InputTypeValue; type?: InputTypeValue;
label?: (() => Promise<string>) | string; labelTKey?: (() => Promise<string>) | string;
placeholder?: (() => Promise<string>) | string; placeholderTKey?: (() => Promise<string>) | string;
dataFieldName?: string; dataFieldName?: string;
min?: (() => Promise<number>) | number; min?: (() => Promise<number>) | number;
max?: (() => Promise<number>) | number; max?: (() => Promise<number>) | number;
step?: (() => Promise<number>) | number; step?: (() => Promise<number>) | number;
trueLabel?: (() => Promise<string>) | string; trueLabelTKey?: (() => Promise<string>) | string;
falseLabel?: (() => Promise<string>) | string; falseLabelTKey?: (() => Promise<string>) | string;
choices?: (() => Promise<ChoiceItem[]>) | ChoiceItem[]; choices?: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
uiType?: string; uiType?: string;
errorMessage?: string; errorMessage?: string;
@@ -100,18 +100,21 @@ export const updateContextWithDecorator = <T extends keyof DecoratorProperties,
export const buildSmartUiDescriptor = (className: string, target: unknown): void => { export const buildSmartUiDescriptor = (className: string, target: unknown): void => {
const context = Reflect.getMetadata(className, target) as Map<string, DecoratorProperties>; const context = Reflect.getMetadata(className, target) as Map<string, DecoratorProperties>;
const smartUiDescriptor = mapToSmartUiDescriptor(context); const smartUiDescriptor = mapToSmartUiDescriptor(className, context);
Reflect.defineMetadata(className, smartUiDescriptor, target); Reflect.defineMetadata(className, smartUiDescriptor, target);
}; };
export const mapToSmartUiDescriptor = (context: Map<string, DecoratorProperties>): SelfServeDescriptor => { export const mapToSmartUiDescriptor = (
className: string,
context: Map<string, DecoratorProperties>
): SelfServeDescriptor => {
const root = context.get("root"); const root = context.get("root");
context.delete("root"); context.delete("root");
const inputNames: string[] = []; const inputNames: string[] = [];
const smartUiDescriptor: SelfServeDescriptor = { const smartUiDescriptor: SelfServeDescriptor = {
root: { root: {
id: "root", id: className,
info: root?.info, info: root?.info,
children: [], children: [],
}, },
@@ -147,7 +150,7 @@ const addToDescriptor = (
const getInput = (value: DecoratorProperties): AnyDisplay => { const getInput = (value: DecoratorProperties): AnyDisplay => {
switch (value.type) { switch (value.type) {
case "number": case "number":
if (!value.label || !value.step || !value.uiType || !value.min || !value.max) { if (!value.labelTKey || !value.step || !value.uiType || !value.min || !value.max) {
value.errorMessage = `label, step, min, max and uiType are required for number input '${value.id}'.`; value.errorMessage = `label, step, min, max and uiType are required for number input '${value.id}'.`;
} }
return value as NumberInput; return value as NumberInput;
@@ -155,17 +158,17 @@ const getInput = (value: DecoratorProperties): AnyDisplay => {
if (value.description) { if (value.description) {
return value as DescriptionDisplay; return value as DescriptionDisplay;
} }
if (!value.label) { if (!value.labelTKey) {
value.errorMessage = `label is required for string input '${value.id}'.`; value.errorMessage = `label is required for string input '${value.id}'.`;
} }
return value as StringInput; return value as StringInput;
case "boolean": case "boolean":
if (!value.label || !value.trueLabel || !value.falseLabel) { if (!value.labelTKey || !value.trueLabelTKey || !value.falseLabelTKey) {
value.errorMessage = `label, truelabel and falselabel are required for boolean input '${value.id}'.`; value.errorMessage = `label, truelabel and falselabel are required for boolean input '${value.id}'.`;
} }
return value as BooleanInput; return value as BooleanInput;
default: default:
if (!value.label || !value.choices) { if (!value.labelTKey || !value.choices) {
value.errorMessage = `label and choices are required for Choice input '${value.id}'.`; value.errorMessage = `label and choices are required for Choice input '${value.id}'.`;
} }
return value as ChoiceInput; return value as ChoiceInput;

View File

@@ -1,33 +1,78 @@
import { RefreshResult } from "../SelfServeTypes"; import { RefreshResult } from "../SelfServeTypes";
import { userContext } from "../../UserContext";
import { armRequest } from "../../Utils/arm/request";
import { configContext } from "../../ConfigContext";
import { SqlxServiceResource, UpdateDedicatedGatewayRequestParameters } from "./SqlxTypes";
const apiVersion = "2020-06-01-preview";
export enum ResourceStatus {
Running,
Creating,
Updating,
Deleting,
}
export interface DedicatedGatewayResponse { export interface DedicatedGatewayResponse {
sku: string; sku: string;
instances: number; instances: number;
status: string;
endpoint: string;
} }
export const getRegionSpecificMinInstances = async (): Promise<number> => { export const getPath = (subscriptionId: string, resourceGroup: string, name: string): string => {
// TODO: write RP call to get min number of instances needed for this region return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${name}/services/sqlx`;
throw new Error("getRegionSpecificMinInstances not implemented");
}; };
export const getRegionSpecificMaxInstances = async (): Promise<number> => { export const updateDedicatedGatewayResource = async (sku: string, instances: number): Promise<void> => {
// TODO: write RP call to get max number of instances needed for this region const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name);
throw new Error("getRegionSpecificMaxInstances not implemented"); const body: UpdateDedicatedGatewayRequestParameters = {
properties: {
instanceSize: sku,
instanceCount: instances,
serviceType: "Sqlx",
},
};
return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "PUT", apiVersion, body });
}; };
export const updateDedicatedGatewayProvisioning = async (sku: string, instances: number): Promise<void> => { export const deleteDedicatedGatewayResource = async (): Promise<void> => {
// TODO: write RP call to update dedicated gateway provisioning const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name);
throw new Error( return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "DELETE", apiVersion });
`updateDedicatedGatewayProvisioning not implemented. Parameters- sku: ${sku}, instances:${instances}`
);
}; };
export const initializeDedicatedGatewayProvisioning = async (): Promise<DedicatedGatewayResponse> => { export const getDedicatedGatewayResource = async (): Promise<SqlxServiceResource> => {
// TODO: write RP call to initialize UI for dedicated gateway provisioning const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name);
throw new Error("initializeDedicatedGatewayProvisioning not implemented"); return armRequest<SqlxServiceResource>({ host: configContext.ARM_ENDPOINT, path, method: "GET", apiVersion });
};
export const getCurrentProvisioningState = async (): Promise<DedicatedGatewayResponse> => {
try {
const response = await getDedicatedGatewayResource();
return {
sku: response.properties.instanceSize,
instances: response.properties.instanceCount,
status: response.properties.status,
endpoint: response.properties.sqlxEndPoint,
};
} catch (e) {
return { sku: undefined, instances: undefined, status: undefined, endpoint: undefined };
}
}; };
export const refreshDedicatedGatewayProvisioning = async (): Promise<RefreshResult> => { export const refreshDedicatedGatewayProvisioning = async (): Promise<RefreshResult> => {
// TODO: write RP call to check if dedicated gateway update has gone through try {
throw new Error("refreshDedicatedGatewayProvisioning not implemented"); const response = await getDedicatedGatewayResource();
if (response.properties.status === ResourceStatus.Running.toString()) {
return { isUpdateInProgress: false, notificationMessage: undefined };
} else if (response.properties.status === ResourceStatus.Creating.toString()) {
return { isUpdateInProgress: true, notificationMessage: "CreateMessage" };
} else if (response.properties.status === ResourceStatus.Deleting.toString()) {
return { isUpdateInProgress: true, notificationMessage: "DeleteMessage" };
} else {
return { isUpdateInProgress: true, notificationMessage: "UpdateMessage" };
}
} catch {
return { isUpdateInProgress: false, notificationMessage: undefined };
}
}; };

View File

@@ -6,9 +6,18 @@ import {
RefreshResult, RefreshResult,
SelfServeBaseClass, SelfServeBaseClass,
SelfServeNotification, SelfServeNotification,
SelfServeNotificationType,
SmartUiInput, SmartUiInput,
} from "../SelfServeTypes"; } from "../SelfServeTypes";
import { refreshDedicatedGatewayProvisioning } from "./SqlX.rp"; import {
ResourceStatus,
refreshDedicatedGatewayProvisioning,
updateDedicatedGatewayResource,
deleteDedicatedGatewayResource,
getCurrentProvisioningState,
} from "./SqlX.rp";
let disableAttributesOnDedicatedGatewayChange = false;
const onEnableDedicatedGatewayChange = ( const onEnableDedicatedGatewayChange = (
currentState: Map<string, SmartUiInput>, currentState: Map<string, SmartUiInput>,
@@ -16,31 +25,38 @@ const onEnableDedicatedGatewayChange = (
): Map<string, SmartUiInput> => { ): Map<string, SmartUiInput> => {
const sku = currentState.get("sku"); const sku = currentState.get("sku");
const instances = currentState.get("instances"); const instances = currentState.get("instances");
const isSkuHidden = newValue === undefined || !(newValue as boolean); const hideAttributes = newValue === undefined || !(newValue as boolean);
currentState.set("enableDedicatedGateway", { value: newValue }); currentState.set("enableDedicatedGateway", { value: newValue });
currentState.set("sku", { value: sku.value, hidden: isSkuHidden }); currentState.set("sku", {
currentState.set("instances", { value: instances.value, hidden: isSkuHidden }); value: sku.value,
hidden: hideAttributes,
disabled: disableAttributesOnDedicatedGatewayChange,
});
currentState.set("instances", {
value: instances.value,
hidden: hideAttributes,
disabled: disableAttributesOnDedicatedGatewayChange,
});
return currentState; return currentState;
}; };
const skuDropDownItems: ChoiceItem[] = [
{ label: "CosmosD4s", key: "Cosmos.D4s" },
{ label: "CosmosD8s", key: "Cosmos.D8s" },
{ label: "CosmosD16s", key: "Cosmos.D16s" },
{ label: "CosmosD32s", key: "Cosmos.D32s" },
];
const getSkus = async (): Promise<ChoiceItem[]> => { const getSkus = async (): Promise<ChoiceItem[]> => {
// TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. return skuDropDownItems;
throw new Error("getSkus not implemented.");
}; };
const getInstancesMin = async (): Promise<number> => { const getInstancesMin = async (): Promise<number> => {
// TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. return 1;
throw new Error("getInstancesMin not implemented.");
}; };
const getInstancesMax = async (): Promise<number> => { const getInstancesMax = async (): Promise<number> => {
// TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. return 5;
throw new Error("getInstancesMax not implemented.");
};
const validate = (currentValues: Map<string, SmartUiInput>): void => {
// TODO: add cusom validation logic to be called before Saving the data.
throw new Error(`validate not implemented. No. of properties to validate: ${currentValues.size}`);
}; };
@IsDisplayable() @IsDisplayable()
@@ -50,22 +66,80 @@ export default class SqlX extends SelfServeBaseClass {
}; };
public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<SelfServeNotification> => { public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<SelfServeNotification> => {
validate(currentValues); const response = await getCurrentProvisioningState();
// TODO: add pre processing logic before calling the updateDedicatedGatewayProvisioning() RP call.
throw new Error(`onSave not implemented. No. of properties to save: ${currentValues.size}`); // null implies the resource has not been provisioned.
if (response.status !== undefined && response.status !== ResourceStatus.Running.toString()) {
switch (response.status) {
case ResourceStatus.Creating.toString():
return { message: "CreateMessage", type: SelfServeNotificationType.error };
case ResourceStatus.Updating.toString():
return { message: "UpdateMessage", type: SelfServeNotificationType.error };
case ResourceStatus.Deleting.toString():
return { message: "DeleteMessage", type: SelfServeNotificationType.error };
default:
return { message: "CannotSave", type: SelfServeNotificationType.error };
}
}
const enableDedicatedGateway = currentValues.get("enableDedicatedGateway")?.value as boolean;
if (response.status !== undefined) {
if (!enableDedicatedGateway) {
try {
await deleteDedicatedGatewayResource();
return { message: "DedicatedGateway resource will be deleted.", type: SelfServeNotificationType.info };
} catch (e) {
return {
message: "Deleting Dedicated Gateway resource failed. DedicatedGateway will not be deleted.",
type: SelfServeNotificationType.error,
};
}
} else {
// Check for scaling up/down/in/out
}
} else {
if (enableDedicatedGateway) {
const sku = currentValues.get("sku")?.value as string;
const instances = currentValues.get("instances").value as number;
try {
await updateDedicatedGatewayResource(sku, instances);
return { message: "Dedicated Gateway resource will be provisioned.", type: SelfServeNotificationType.info };
} catch (e) {
return {
message: "Updating Dedicated Gateway resource failed. Dedicated Gateway will not be updated.",
type: SelfServeNotificationType.error,
};
}
}
}
return { message: "No updates were applied at this time", type: SelfServeNotificationType.warning };
}; };
public initialize = async (): Promise<Map<string, SmartUiInput>> => { public initialize = async (): Promise<Map<string, SmartUiInput>> => {
// TODO: get initialization data from initializeDedicatedGatewayProvisioning() RP call. // Based on the RP call enableDedicatedGateway will be true if it has not yet been enabled and false if it has.
throw new Error("onSave not implemented"); const defaults = new Map<string, SmartUiInput>();
const enableDedicatedGateway = false;
defaults.set("enableDedicatedGateway", { value: enableDedicatedGateway, hidden: false, disabled: false });
defaults.set("sku", { value: "Cosmos.D4s", hidden: !enableDedicatedGateway, disabled: false });
defaults.set("instances", { value: await getInstancesMin(), hidden: !enableDedicatedGateway, disabled: false });
const response = await getCurrentProvisioningState();
if (response.status !== undefined) {
disableAttributesOnDedicatedGatewayChange = true;
defaults.set("enableDedicatedGateway", { value: true, hidden: false, disabled: false });
defaults.set("sku", { value: response.sku, hidden: false, disabled: true });
defaults.set("instances", { value: response.instances, hidden: false, disabled: true });
}
return defaults;
}; };
@Values({ @Values({
description: { description: {
text: "Provisioning dedicated gateways for SqlX accounts.", textTKey: "DedicatedGatewayDescription",
link: { link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
text: "Learn more about dedicated gateway.", textTKey: "LearnAboutDedicatedGateway",
}, },
}, },
}) })
@@ -73,21 +147,21 @@ export default class SqlX extends SelfServeBaseClass {
@OnChange(onEnableDedicatedGatewayChange) @OnChange(onEnableDedicatedGatewayChange)
@Values({ @Values({
label: "Dedicated Gateway", labelTKey: "DedicatedGateway",
trueLabel: "Enable", trueLabelTKey: "Enable",
falseLabel: "Disable", falseLabelTKey: "Disable",
}) })
enableDedicatedGateway: boolean; enableDedicatedGateway: boolean;
@Values({ @Values({
label: "SKUs", labelTKey: "SKUs",
choices: getSkus, choices: getSkus,
placeholder: "Select SKUs", placeholderTKey: "Select SKUs",
}) })
sku: ChoiceItem; sku: ChoiceItem;
@Values({ @Values({
label: "Number of instances", labelTKey: "NumberOfInstances",
min: getInstancesMin, min: getInstancesMin,
max: getInstancesMax, max: getInstancesMax,
step: 1, step: 1,

View File

@@ -0,0 +1,31 @@
export type SqlxServiceResource = {
id: string;
name: string;
type: string;
properties: SqlxServiceProps;
locations: SqlxServiceLocations;
};
export type SqlxServiceProps = {
serviceType: string;
creationTime: string;
status: string;
instanceSize: string;
instanceCount: number;
sqlxEndPoint: string;
};
export type SqlxServiceLocations = {
location: string;
status: string;
sqlxEndpoint: string;
};
export type UpdateDedicatedGatewayRequestParameters = {
properties: UpdateDedicatedGatewayRequestProperties;
};
export type UpdateDedicatedGatewayRequestProperties = {
instanceSize: string;
instanceCount: number;
serviceType: string;
};

View File

@@ -1,686 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelfServeComponent message bar and spinner snapshots 1`] = ` exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
<div <Translation>
style={ <Component />
Object { </Translation>
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledMessageBarBase
messageBarType={0}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
refresh performed successfully
</StyledMessageBarBase>
<StyledMessageBarBase
messageBarType={0}
onDismiss={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
submitted successfully
</StyledMessageBarBase>
<SmartUiComponent
currentValues={
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
Object {
"initialize": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"analyticalStore",
"database",
],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [MockFunction] {
"calls": Array [
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"root": Object {
"children": Array [
Object {
"id": "throughput",
"info": undefined,
"input": Object {
"dataFieldName": "throughput",
"defaultValue": 400,
"label": "Throughput (input)",
"max": 500,
"min": 400,
"placeholder": undefined,
"step": 10,
"type": "number",
"uiType": "Spinner",
},
},
Object {
"id": "containerId",
"info": undefined,
"input": Object {
"dataFieldName": "containerId",
"label": "Container id",
"placeholder": undefined,
"type": "string",
},
},
Object {
"id": "analyticalStore",
"info": undefined,
"input": Object {
"dataFieldName": "analyticalStore",
"defaultValue": true,
"falseLabel": "Disabled",
"label": "Analytical Store",
"placeholder": undefined,
"trueLabel": "Enabled",
"type": "boolean",
},
},
Object {
"id": "database",
"info": undefined,
"input": Object {
"choices": Array [
Object {
"key": "db1",
"label": "Database 1",
},
Object {
"key": "db2",
"label": "Database 2",
},
Object {
"key": "db3",
"label": "Database 3",
},
],
"dataFieldName": "database",
"defaultKey": "db2",
"label": "Database",
"placeholder": undefined,
"type": "object",
},
},
],
"id": "root",
"info": Object {
"link": Object {
"href": "https://aka.ms/azure-cosmos-db-pricing",
"text": "More Details",
},
"message": "Start at $24/mo per database",
},
},
}
}
disabled={true}
onError={[Function]}
onInputChange={[Function]}
/>
</Stack>
</div>
`; `;
exports[`SelfServeComponent message bar and spinner snapshots 2`] = ` exports[`SelfServeComponent message bar and spinner snapshots 2`] = `
<div <Translation>
style={ <Component />
Object { </Translation>
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledMessageBarBase
messageBarType={0}
onDismiss={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
submitted successfully
</StyledMessageBarBase>
<SmartUiComponent
currentValues={
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
Object {
"initialize": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"analyticalStore",
"database",
],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [MockFunction] {
"calls": Array [
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"root": Object {
"children": Array [
Object {
"id": "throughput",
"info": undefined,
"input": Object {
"dataFieldName": "throughput",
"defaultValue": 400,
"label": "Throughput (input)",
"max": 500,
"min": 400,
"placeholder": undefined,
"step": 10,
"type": "number",
"uiType": "Spinner",
},
},
Object {
"id": "containerId",
"info": undefined,
"input": Object {
"dataFieldName": "containerId",
"label": "Container id",
"placeholder": undefined,
"type": "string",
},
},
Object {
"id": "analyticalStore",
"info": undefined,
"input": Object {
"dataFieldName": "analyticalStore",
"defaultValue": true,
"falseLabel": "Disabled",
"label": "Analytical Store",
"placeholder": undefined,
"trueLabel": "Enabled",
"type": "boolean",
},
},
Object {
"id": "database",
"info": undefined,
"input": Object {
"choices": Array [
Object {
"key": "db1",
"label": "Database 1",
},
Object {
"key": "db2",
"label": "Database 2",
},
Object {
"key": "db3",
"label": "Database 3",
},
],
"dataFieldName": "database",
"defaultKey": "db2",
"label": "Database",
"placeholder": undefined,
"type": "object",
},
},
],
"id": "root",
"info": Object {
"link": Object {
"href": "https://aka.ms/azure-cosmos-db-pricing",
"text": "More Details",
},
"message": "Start at $24/mo per database",
},
},
}
}
disabled={false}
onError={[Function]}
onInputChange={[Function]}
/>
</Stack>
</div>
`; `;
exports[`SelfServeComponent message bar and spinner snapshots 3`] = ` exports[`SelfServeComponent message bar and spinner snapshots 3`] = `
<div <Translation>
style={ <Component />
Object { </Translation>
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledSpinnerBase
size={3}
styles={
Object {
"root": Object {
"height": "100%",
"justifyContent": "center",
"textAlign": "center",
"width": "100%",
},
}
}
/>
</Stack>
</div>
`; `;
exports[`SelfServeComponent message bar and spinner snapshots 4`] = ` exports[`SelfServeComponent message bar and spinner snapshots 4`] = `
@@ -692,195 +27,7 @@ exports[`SelfServeComponent message bar and spinner snapshots 4`] = `
`; `;
exports[`SelfServeComponent should render and honor save, discard, refresh actions 1`] = ` exports[`SelfServeComponent should render and honor save, discard, refresh actions 1`] = `
<div <Translation>
style={ <Component />
Object { </Translation>
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<SmartUiComponent
currentValues={
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
Object {
"initialize": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"analyticalStore",
"database",
],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [MockFunction],
"root": Object {
"children": Array [
Object {
"id": "throughput",
"info": undefined,
"input": Object {
"dataFieldName": "throughput",
"defaultValue": 400,
"label": "Throughput (input)",
"max": 500,
"min": 400,
"placeholder": undefined,
"step": 10,
"type": "number",
"uiType": "Spinner",
},
},
Object {
"id": "containerId",
"info": undefined,
"input": Object {
"dataFieldName": "containerId",
"label": "Container id",
"placeholder": undefined,
"type": "string",
},
},
Object {
"id": "analyticalStore",
"info": undefined,
"input": Object {
"dataFieldName": "analyticalStore",
"defaultValue": true,
"falseLabel": "Disabled",
"label": "Analytical Store",
"placeholder": undefined,
"trueLabel": "Enabled",
"type": "boolean",
},
},
Object {
"id": "database",
"info": undefined,
"input": Object {
"choices": Array [
Object {
"key": "db1",
"label": "Database 1",
},
Object {
"key": "db2",
"label": "Database 2",
},
Object {
"key": "db3",
"label": "Database 3",
},
],
"dataFieldName": "database",
"defaultKey": "db2",
"label": "Database",
"placeholder": undefined,
"type": "object",
},
},
],
"id": "root",
"info": Object {
"link": Object {
"href": "https://aka.ms/azure-cosmos-db-pricing",
"text": "More Details",
},
"message": "Start at $24/mo per database",
},
},
}
}
disabled={false}
onError={[Function]}
onInputChange={[Function]}
/>
</Stack>
</div>
`; `;

View File

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

View File

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

View File

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

View File

@@ -41,12 +41,13 @@ export function useKnockoutExplorer(config: ConfigContext, explorerParams: Explo
if (config) { if (config) {
if (config.platform === Platform.Hosted) { if (config.platform === Platform.Hosted) {
await configureHosted(config); await configureHosted(config);
applyExplorerBindings(explorer);
} else if (config.platform === Platform.Emulator) { } else if (config.platform === Platform.Emulator) {
configureEmulator(); configureEmulator();
applyExplorerBindings(explorer);
} else if (config.platform === Platform.Portal) { } else if (config.platform === Platform.Portal) {
configurePortal(); configurePortal();
} }
applyExplorerBindings(explorer);
} }
}; };
effect(); effect();
@@ -237,13 +238,14 @@ function configurePortal() {
); );
console.dir(message); console.dir(message);
explorer.configure(message); explorer.configure(message);
applyExplorerBindings(explorer);
} }
} }
// In the Portal, configuration of Explorer happens via iframe message // In the Portal, configuration of Explorer happens via iframe message
window.addEventListener( window.addEventListener(
"message", "message",
(event) => { (event) => {
console.dir(event);
if (isInvalidParentFrameOrigin(event)) { if (isInvalidParentFrameOrigin(event)) {
return; return;
} }
@@ -265,6 +267,7 @@ function configurePortal() {
} }
explorer.configure(inputs); explorer.configure(inputs);
applyExplorerBindings(explorer);
} }
}, },
false false

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

31
src/i18n.ts Normal file
View File

@@ -0,0 +1,31 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import XHR from "i18next-http-backend";
import EnglishTranslations from "./Localization/en/translations.json";
i18n
.use(XHR)
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: EnglishTranslations,
},
fallbackLng: "en",
detection: { order: ["navigator", "cookie", "localStorage", "sessionStorage", "querystring", "htmlTag"] },
debug: process.env.NODE_ENV === "development",
ns: ["translations"],
defaultNS: "translations",
keySeparator: ".",
interpolation: {
formatSeparator: ",",
},
react: {
wait: true,
bindI18n: "languageChanged loaded",
bindI18nStore: "added removed",
nsMode: "default",
useSuspense: false,
},
});

Some files were not shown because too many files have changed in this diff Show More