mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-24 11:21:23 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0a98dce7 | ||
|
|
67ebce444f | ||
|
|
a09bcc7197 | ||
|
|
b2390e23e7 | ||
|
|
f922103e5c | ||
|
|
22d8a7a1be | ||
|
|
4210e0752b | ||
|
|
b217d4be1b | ||
|
|
81fd442fad | ||
|
|
87f7dd2230 | ||
|
|
9926fd97a2 | ||
|
|
faa98de9e9 | ||
|
|
2a7546e0de | ||
|
|
4b442dd869 | ||
|
|
f0b4737313 | ||
|
|
8dc5ed590a | ||
|
|
afaa844d28 | ||
|
|
3e5a876ef2 | ||
|
|
51abf1560a | ||
|
|
1c0fed88c0 | ||
|
|
93cfd52e36 | ||
|
|
3fd014ddad | ||
|
|
3b6fda4fa5 | ||
|
|
db7c45c9b8 | ||
|
|
4f6b75fe79 | ||
|
|
5038a01079 | ||
|
|
e0063c76d9 | ||
|
|
9278654479 | ||
|
|
59113d7bbf | ||
|
|
88d8200c14 | ||
|
|
6aaddd9c60 | ||
|
|
f8ede0cc1e | ||
|
|
bddb288a89 | ||
|
|
a14d20a88e | ||
|
|
f1db1ed978 | ||
|
|
86a483c3a4 | ||
|
|
263262a040 | ||
|
|
bd4d8da065 | ||
|
|
59ec18cd9b | ||
|
|
49bf8c60db | ||
|
|
b0b973b21a | ||
|
|
3529e80f0d |
@@ -87,7 +87,7 @@ src/Explorer/DataSamples/ContainerSampleGenerator.test.ts
|
||||
src/Explorer/DataSamples/ContainerSampleGenerator.ts
|
||||
src/Explorer/DataSamples/DataSamplesUtil.test.ts
|
||||
src/Explorer/DataSamples/DataSamplesUtil.ts
|
||||
src/Explorer/Explorer.ts
|
||||
src/Explorer/Explorer.tsx
|
||||
src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.test.ts
|
||||
src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts
|
||||
src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -9,6 +9,20 @@ on:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
codemetrics:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Log Code Metrics"
|
||||
if: github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
- run: npm ci
|
||||
- run: node utils/codeMetrics.js
|
||||
env:
|
||||
CODE_METRICS_APP_ID: ${{ secrets.CODE_METRICS_APP_ID }}
|
||||
compile:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Compile TypeScript"
|
||||
@@ -142,6 +156,7 @@ jobs:
|
||||
run: |
|
||||
npm ci
|
||||
npm start &
|
||||
node utils/cleanupDBs.js
|
||||
npm run wait-for-server
|
||||
npm run test:e2e
|
||||
shell: bash
|
||||
@@ -151,6 +166,8 @@ jobs:
|
||||
PORTAL_RUNNER_RESOURCE_GROUP: ${{ secrets.PORTAL_RUNNER_RESOURCE_GROUP }}
|
||||
PORTAL_RUNNER_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT }}
|
||||
PORTAL_RUNNER_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY }}
|
||||
PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT: ${{ secrets.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT }}
|
||||
PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY: ${{ secrets.PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY }}
|
||||
NOTEBOOKS_TEST_RUNNER_TENANT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_TENANT_ID }}
|
||||
NOTEBOOKS_TEST_RUNNER_CLIENT_ID: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_ID }}
|
||||
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET: ${{ secrets.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET }}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com"
|
||||
"JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com",
|
||||
"ENABLE_GALLERY_PUBLISH": true
|
||||
}
|
||||
|
||||
3
images/notebook/publish_content.svg
Normal file
3
images/notebook/publish_content.svg
Normal 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 |
@@ -57,6 +57,13 @@
|
||||
|
||||
@FocusColor: #605e5c;
|
||||
|
||||
@GalleryBackgroundColor: #fdfdfd;
|
||||
|
||||
//Icons
|
||||
@InfoIconColor: #0072c6;
|
||||
@WarningIconColor: #db7500;
|
||||
@ErrorIconColor: #b91f26;
|
||||
|
||||
/******************************************************************************
|
||||
METRICS
|
||||
/******************************************************************************/
|
||||
|
||||
@@ -1523,6 +1523,21 @@ p {
|
||||
.tooltipVisible();
|
||||
}
|
||||
|
||||
.inputTooltip {
|
||||
.inputTooltip();
|
||||
}
|
||||
|
||||
.inputTooltip .inputTooltipText {
|
||||
top: -68px;
|
||||
.inputTooltipText();
|
||||
}
|
||||
|
||||
.inputTooltip .inputTooltipText::after {
|
||||
border-width: @MediumSpace @MediumSpace 0 @MediumSpace;
|
||||
top: 55px;
|
||||
.inputTooltipTextAfter();
|
||||
}
|
||||
|
||||
.infoTooltip a {
|
||||
color: @AccentHigh;
|
||||
}
|
||||
@@ -1694,6 +1709,7 @@ input::-webkit-calendar-picker-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.contextual-pane .paneErrorDetailsContainer {
|
||||
@@ -2083,7 +2099,7 @@ a:link {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -3027,3 +3043,45 @@ settings-pane {
|
||||
.collapsibleSection :hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.messageBarInfoIcon {
|
||||
color: @InfoIconColor;
|
||||
}
|
||||
|
||||
.messageBarWarningIcon {
|
||||
color: @WarningIconColor;
|
||||
}
|
||||
|
||||
.freeTierInfoBanner {
|
||||
background-color: @BaseLow;
|
||||
display: inline-flex;
|
||||
padding: @DefaultSpace;
|
||||
width: 100%;
|
||||
|
||||
.freeTierInfoIcon img {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.freeTierInfoMessage {
|
||||
margin: auto 0;
|
||||
padding-left: @MediumSpace;
|
||||
}
|
||||
}
|
||||
|
||||
.freeTierInlineWarning {
|
||||
display: inline-flex;
|
||||
padding: 8px 8px 8px 0;
|
||||
width: 100%;
|
||||
|
||||
.freeTierWarningIcon img {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.freeTierWarningMessage {
|
||||
margin: auto 0;
|
||||
padding-left: @SmallSpace;
|
||||
}
|
||||
}
|
||||
|
||||
4265
package-lock.json
generated
4265
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -10,10 +10,10 @@
|
||||
"@azure/identity": "1.2.1",
|
||||
"@babel/plugin-proposal-class-properties": "7.12.1",
|
||||
"@babel/plugin-proposal-decorators": "7.12.12",
|
||||
"@jupyterlab/services": "6.0.0-rc.2",
|
||||
"@jupyterlab/terminal": "3.0.0-rc.2",
|
||||
"@jupyterlab/services": "6.0.2",
|
||||
"@jupyterlab/terminal": "3.0.3",
|
||||
"@microsoft/applicationinsights-web": "2.5.9",
|
||||
"@nteract/commutable": "7.3.2",
|
||||
"@nteract/commutable": "7.4.2",
|
||||
"@nteract/connected-components": "6.8.2",
|
||||
"@nteract/core": "15.1.0",
|
||||
"@nteract/data-explorer": "8.0.3",
|
||||
@@ -64,6 +64,9 @@
|
||||
"eslint-plugin-react": "7.20.0",
|
||||
"hasher": "1.2.0",
|
||||
"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",
|
||||
"is-ci": "2.0.0",
|
||||
"jquery": "3.5.1",
|
||||
@@ -86,6 +89,7 @@
|
||||
"react-dnd-html5-backend": "9.4.0",
|
||||
"react-dom": "16.13.1",
|
||||
"react-hotkeys": "2.0.0",
|
||||
"react-i18next": "11.8.5",
|
||||
"react-notification-system": "0.2.17",
|
||||
"react-redux": "7.1.3",
|
||||
"redux": "4.0.4",
|
||||
@@ -124,7 +128,7 @@
|
||||
"@types/prop-types": "15.5.8",
|
||||
"@types/puppeteer": "3.0.1",
|
||||
"@types/q": "1.5.1",
|
||||
"@types/react": "16.9.56",
|
||||
"@types/react": "17.0.0",
|
||||
"@types/react-dom": "17.0.0",
|
||||
"@types/react-notification-system": "0.2.39",
|
||||
"@types/react-redux": "7.1.7",
|
||||
@@ -151,6 +155,7 @@
|
||||
"eslint-plugin-prefer-arrow": "1.2.2",
|
||||
"eslint-plugin-react-hooks": "4.2.0",
|
||||
"expose-loader": "0.7.5",
|
||||
"fast-glob": "3.2.5",
|
||||
"file-loader": "2.0.0",
|
||||
"fs-extra": "7.0.0",
|
||||
"html-loader": "0.5.5",
|
||||
|
||||
@@ -119,7 +119,9 @@ export class Features {
|
||||
public static readonly enableSchema = "enableschema";
|
||||
public static readonly enableSDKoperations = "enablesdkoperations";
|
||||
public static readonly showMinRUSurvey = "showminrusurvey";
|
||||
public static readonly enableDatabaseSettingsTabV1 = "enabledbsettingsv1";
|
||||
public static readonly selfServeType = "selfservetype";
|
||||
public static readonly enableKOPanel = "enablekopanel";
|
||||
}
|
||||
|
||||
// flight names returned from the portal are always lowercase
|
||||
@@ -128,6 +130,7 @@ export class Flights {
|
||||
public static readonly MongoIndexEditor = "mongoindexeditor";
|
||||
public static readonly MongoIndexing = "mongoindexing";
|
||||
public static readonly AutoscaleTest = "autoscaletest";
|
||||
public static readonly GalleryPublish = "gallerypublish";
|
||||
}
|
||||
|
||||
export class AfecFeatures {
|
||||
|
||||
@@ -76,7 +76,7 @@ export const getCollectionUsageSizeInKB = async (databaseName: string, container
|
||||
return dataUsageSizeInKb + indexUsageSizeInKb;
|
||||
} catch (error) {
|
||||
handleError(error, "getCollectionUsageSize");
|
||||
throw error;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export enum Platform {
|
||||
Emulator = "Emulator",
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
export interface ConfigContext {
|
||||
platform: Platform;
|
||||
allowedParentFrameOrigins: string[];
|
||||
gitSha?: string;
|
||||
@@ -26,6 +26,7 @@ interface ConfigContext {
|
||||
GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it.
|
||||
hostedExplorerURL: string;
|
||||
armAPIVersion?: string;
|
||||
ENABLE_GALLERY_PUBLISH?: boolean;
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
@@ -79,7 +80,11 @@ if (process.env.NODE_ENV === "development") {
|
||||
|
||||
export async function initializeConfiguration(): Promise<ConfigContext> {
|
||||
try {
|
||||
const response = await fetch("./config.json");
|
||||
const response = await fetch("./config.json", {
|
||||
headers: {
|
||||
"If-None-Match": "", // disable client side cache
|
||||
},
|
||||
});
|
||||
if (response.status === 200) {
|
||||
try {
|
||||
const { allowedParentFrameOrigins, ...externalConfig } = await response.json();
|
||||
|
||||
@@ -33,7 +33,6 @@ export enum MessageTypes {
|
||||
CreateWorkspace,
|
||||
CreateSparkPool,
|
||||
RefreshDatabaseAccount,
|
||||
InitTestExplorer,
|
||||
}
|
||||
|
||||
export { Versions, ActionContracts, Diagnostics };
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
TriggerDefinition,
|
||||
UserDefinedFunctionDefinition,
|
||||
} from "@azure/cosmos";
|
||||
import Q from "q";
|
||||
import { CommandButtonComponentProps } from "../Explorer/Controls/CommandButton/CommandButtonComponent";
|
||||
import Explorer from "../Explorer/Explorer";
|
||||
import { ConsoleData } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
@@ -92,6 +91,7 @@ export interface Database extends TreeNode {
|
||||
onDeleteDatabaseContextMenuClick(source: Database, event: MouseEvent | KeyboardEvent): void;
|
||||
onSettingsClick: () => void;
|
||||
loadOffer(): Promise<void>;
|
||||
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
|
||||
}
|
||||
|
||||
export interface CollectionBase extends TreeNode {
|
||||
@@ -109,7 +109,7 @@ export interface CollectionBase extends TreeNode {
|
||||
|
||||
onDocumentDBDocumentsClick(): void;
|
||||
onNewQueryClick(source: any, event: MouseEvent, queryText?: string): void;
|
||||
expandCollection(): Q.Promise<any>;
|
||||
expandCollection(): void;
|
||||
collapseCollection(): void;
|
||||
getDatabase(): Database;
|
||||
}
|
||||
@@ -138,7 +138,6 @@ export interface Collection extends CollectionBase {
|
||||
openTab(): void;
|
||||
|
||||
onSettingsClick: () => Promise<void>;
|
||||
onDeleteCollectionContextMenuClick(source: Collection, event: MouseEvent): void;
|
||||
|
||||
onNewGraphClick(): void;
|
||||
onNewMongoQueryClick(source: any, event: MouseEvent, queryText?: string): void;
|
||||
@@ -176,9 +175,10 @@ export interface Collection extends CollectionBase {
|
||||
|
||||
onDragOver(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;
|
||||
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,10 +293,6 @@ export interface DocumentsTabOptions extends TabOptions {
|
||||
resourceTokenPartitionKey?: string;
|
||||
}
|
||||
|
||||
export interface SettingsTabV2Options extends TabOptions {
|
||||
getPendingNotification: Q.Promise<DataModels.Notification>;
|
||||
}
|
||||
|
||||
export interface ConflictsTabOptions extends TabOptions {
|
||||
partitionKey: DataModels.PartitionKey;
|
||||
conflictIds: ko.ObservableArray<ConflictId>;
|
||||
@@ -363,7 +359,8 @@ export enum CollectionTabKind {
|
||||
Gallery = 17,
|
||||
NotebookViewer = 18,
|
||||
Schema = 19,
|
||||
SettingsV2 = 20,
|
||||
CollectionSettingsV2 = 20,
|
||||
DatabaseSettingsV2 = 21,
|
||||
}
|
||||
|
||||
export enum TerminalKind {
|
||||
|
||||
@@ -45,7 +45,8 @@ describe("Component Registerer", () => {
|
||||
});
|
||||
|
||||
it("should register settings-tab-v2 component", () => {
|
||||
expect(ko.components.isRegistered("settings-tab-v2")).toBe(true);
|
||||
expect(ko.components.isRegistered("database-settings-tab-v2")).toBe(true);
|
||||
expect(ko.components.isRegistered("collection-settings-tab-v2")).toBe(true);
|
||||
});
|
||||
|
||||
it("should register query-tab component", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ ko.components.register("mongo-documents-tab", new TabComponents.MongoDocumentsTa
|
||||
ko.components.register("stored-procedure-tab", new TabComponents.StoredProcedureTab());
|
||||
ko.components.register("trigger-tab", new TabComponents.TriggerTab());
|
||||
ko.components.register("user-defined-function-tab", new TabComponents.UserDefinedFunctionTab());
|
||||
ko.components.register("settings-tab-v2", new TabComponents.SettingsTabV2());
|
||||
ko.components.register("collection-settings-tab-v2", new TabComponents.SettingsTabV2());
|
||||
ko.components.register("query-tab", new TabComponents.QueryTab());
|
||||
ko.components.register("tables-query-tab", new TabComponents.QueryTablesTab());
|
||||
ko.components.register("graph-tab", new TabComponents.GraphTab());
|
||||
@@ -45,6 +45,7 @@ ko.components.register("notebook-viewer-tab", new TabComponents.NotebookViewerTa
|
||||
|
||||
// Database Tabs
|
||||
ko.components.register("database-settings-tab", new TabComponents.DatabaseSettingsTab());
|
||||
ko.components.register("database-settings-tab-v2", new TabComponents.SettingsTabV2());
|
||||
|
||||
// Panes
|
||||
ko.components.register("add-database-pane", new PaneComponents.AddDatabasePaneComponent());
|
||||
|
||||
@@ -112,10 +112,7 @@ export class ResourceTreeContextMenuButtonFactory {
|
||||
|
||||
items.push({
|
||||
iconSrc: DeleteCollectionIcon,
|
||||
onClick: () => {
|
||||
const selectedCollection: ViewModels.Collection = container.findSelectedCollection();
|
||||
selectedCollection && selectedCollection.onDeleteCollectionContextMenuClick(selectedCollection, null);
|
||||
},
|
||||
onClick: () => container.openDeleteCollectionConfirmationPane(),
|
||||
label: container.deleteCollectionText(),
|
||||
styleClass: "deleteCollectionMenuItem",
|
||||
});
|
||||
|
||||
@@ -3,7 +3,13 @@ import { Dialog, DialogType, DialogFooter, IDialogProps } from "office-ui-fabric
|
||||
import { IButtonProps, PrimaryButton, DefaultButton } from "office-ui-fabric-react/lib/Button";
|
||||
import { ITextFieldProps, TextField } from "office-ui-fabric-react/lib/TextField";
|
||||
import { Link } from "office-ui-fabric-react/lib/Link";
|
||||
import { ChoiceGroup, FontIcon, IChoiceGroupProps } from "office-ui-fabric-react";
|
||||
import {
|
||||
ChoiceGroup,
|
||||
FontIcon,
|
||||
IChoiceGroupProps,
|
||||
IProgressIndicatorProps,
|
||||
ProgressIndicator,
|
||||
} from "office-ui-fabric-react";
|
||||
|
||||
export interface TextFieldProps extends ITextFieldProps {
|
||||
label: string;
|
||||
@@ -27,6 +33,7 @@ export interface DialogProps {
|
||||
choiceGroupProps?: IChoiceGroupProps;
|
||||
textFieldProps?: TextFieldProps;
|
||||
linkProps?: LinkProps;
|
||||
progressIndicatorProps?: IProgressIndicatorProps;
|
||||
primaryButtonText: string;
|
||||
secondaryButtonText: string;
|
||||
onPrimaryButtonClick: () => void;
|
||||
@@ -62,13 +69,14 @@ export class DialogComponent extends React.Component<DialogProps, {}> {
|
||||
showCloseButton: this.props.showCloseButton || false,
|
||||
onDismiss: this.props.onDismiss,
|
||||
},
|
||||
modalProps: { isBlocking: this.props.isModal },
|
||||
modalProps: { isBlocking: this.props.isModal, isDarkOverlay: false },
|
||||
minWidth: DIALOG_MIN_WIDTH,
|
||||
maxWidth: DIALOG_MAX_WIDTH,
|
||||
};
|
||||
const choiceGroupProps: IChoiceGroupProps = this.props.choiceGroupProps;
|
||||
const textFieldProps: ITextFieldProps = this.props.textFieldProps;
|
||||
const linkProps: LinkProps = this.props.linkProps;
|
||||
const progressIndicatorProps: IProgressIndicatorProps = this.props.progressIndicatorProps;
|
||||
const primaryButtonProps: IButtonProps = {
|
||||
text: this.props.primaryButtonText,
|
||||
disabled: this.props.primaryButtonDisabled || false,
|
||||
@@ -91,6 +99,7 @@ export class DialogComponent extends React.Component<DialogProps, {}> {
|
||||
{linkProps.linkText} <FontIcon iconName="NavigateExternalInline" />
|
||||
</Link>
|
||||
)}
|
||||
{progressIndicatorProps && <ProgressIndicator {...progressIndicatorProps} />}
|
||||
<DialogFooter>
|
||||
<PrimaryButton {...primaryButtonProps} />
|
||||
{secondaryButtonProps && <DefaultButton {...secondaryButtonProps} />}
|
||||
|
||||
@@ -18,7 +18,6 @@ import * as React from "react";
|
||||
import { IGalleryItem } from "../../../../Juno/JunoClient";
|
||||
import { FileSystemUtil } from "../../../Notebook/FileSystemUtil";
|
||||
import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg";
|
||||
import { StyleConstants } from "../../../../Common/Constants";
|
||||
|
||||
export interface GalleryCardComponentProps {
|
||||
data: IGalleryItem;
|
||||
@@ -38,7 +37,7 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
|
||||
private static readonly cardImageHeight = 144;
|
||||
public static readonly cardHeightToWidthRatio =
|
||||
GalleryCardComponent.cardImageHeight / GalleryCardComponent.CARD_WIDTH;
|
||||
private static readonly cardDescriptionMaxChars = 88;
|
||||
private static readonly cardDescriptionMaxChars = 80;
|
||||
private static readonly cardItemGapBig = 10;
|
||||
private static readonly cardItemGapSmall = 8;
|
||||
|
||||
@@ -54,6 +53,7 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ background: "white" }}
|
||||
aria-label={cardTitle}
|
||||
data-is-focusable="true"
|
||||
tokens={{ width: GalleryCardComponent.CARD_WIDTH, childrenGap: 0 }}
|
||||
@@ -79,12 +79,16 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
|
||||
|
||||
<Card.Section styles={{ root: { padding: GalleryCardComponent.cardItemGapBig } }}>
|
||||
<Text variant="small" nowrap>
|
||||
{this.props.data.tags?.map((tag, index, array) => (
|
||||
<span key={tag}>
|
||||
<Link onClick={(event) => this.onClick(event, () => this.props.onTagClick(tag))}>{tag}</Link>
|
||||
{index === array.length - 1 ? <></> : ", "}
|
||||
</span>
|
||||
))}
|
||||
{this.props.data.tags ? (
|
||||
this.props.data.tags.map((tag, index, array) => (
|
||||
<span key={tag}>
|
||||
<Link onClick={(event) => this.onClick(event, () => this.props.onTagClick(tag))}>{tag}</Link>
|
||||
{index === array.length - 1 ? <></> : ", "}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
@@ -101,13 +105,14 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
|
||||
</Text>
|
||||
|
||||
<Text variant="small" styles={{ root: { height: 36 } }}>
|
||||
{this.props.data.description.substr(0, GalleryCardComponent.cardDescriptionMaxChars)}
|
||||
{this.renderTruncatedDescription()}
|
||||
</Text>
|
||||
|
||||
<span>
|
||||
{this.generateIconText("RedEye", this.props.data.views.toString())}
|
||||
{this.generateIconText("Download", this.props.data.downloads.toString())}
|
||||
{this.props.isFavorite !== undefined &&
|
||||
{this.props.data.views !== undefined && this.generateIconText("RedEye", this.props.data.views.toString())}
|
||||
{this.props.data.downloads !== undefined &&
|
||||
this.generateIconText("Download", this.props.data.downloads.toString())}
|
||||
{this.props.data.favorites !== undefined &&
|
||||
this.generateIconText("Heart", this.props.data.favorites.toString())}
|
||||
</span>
|
||||
</Card.Section>
|
||||
@@ -127,7 +132,7 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
|
||||
{this.props.isFavorite !== undefined &&
|
||||
this.generateIconButtonWithTooltip(
|
||||
this.props.isFavorite ? "HeartFill" : "Heart",
|
||||
this.props.isFavorite ? "Unlike" : "Like",
|
||||
this.props.isFavorite ? "Unfavorite" : "Favorite",
|
||||
"left",
|
||||
this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick
|
||||
)}
|
||||
@@ -144,12 +149,17 @@ export class GalleryCardComponent extends React.Component<GalleryCardComponentPr
|
||||
);
|
||||
}
|
||||
|
||||
private renderTruncatedDescription = (): string => {
|
||||
let truncatedDescription = this.props.data.description.substr(0, GalleryCardComponent.cardDescriptionMaxChars);
|
||||
if (this.props.data.description.length > GalleryCardComponent.cardDescriptionMaxChars) {
|
||||
truncatedDescription = `${truncatedDescription} ...`;
|
||||
}
|
||||
return truncatedDescription;
|
||||
};
|
||||
|
||||
private generateIconText = (iconName: string, text: string): JSX.Element => {
|
||||
return (
|
||||
<Text
|
||||
variant="tiny"
|
||||
styles={{ root: { color: StyleConstants.BaseMedium, paddingRight: GalleryCardComponent.cardItemGapSmall } }}
|
||||
>
|
||||
<Text variant="tiny" styles={{ root: { color: "#605E5C", paddingRight: GalleryCardComponent.cardItemGapSmall } }}>
|
||||
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} /> {text}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,11 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
aria-label="name"
|
||||
data-is-focusable="true"
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"background": "white",
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 0,
|
||||
@@ -88,7 +93,7 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"color": undefined,
|
||||
"color": "#605E5C",
|
||||
"paddingRight": 8,
|
||||
},
|
||||
}
|
||||
@@ -112,7 +117,7 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"color": undefined,
|
||||
"color": "#605E5C",
|
||||
"paddingRight": 8,
|
||||
},
|
||||
}
|
||||
@@ -136,7 +141,7 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"color": undefined,
|
||||
"color": "#605E5C",
|
||||
"paddingRight": 8,
|
||||
},
|
||||
}
|
||||
@@ -185,7 +190,7 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
"gapSpace": 0,
|
||||
}
|
||||
}
|
||||
content="Like"
|
||||
content="Favorite"
|
||||
id="TooltipHost-IconButton-Heart"
|
||||
styles={
|
||||
Object {
|
||||
@@ -197,14 +202,14 @@ exports[`GalleryCardComponent renders 1`] = `
|
||||
}
|
||||
>
|
||||
<CustomizedIconButton
|
||||
ariaLabel="Like"
|
||||
ariaLabel="Favorite"
|
||||
iconProps={
|
||||
Object {
|
||||
"iconName": "Heart",
|
||||
}
|
||||
}
|
||||
onClick={[Function]}
|
||||
title="Like"
|
||||
title="Favorite"
|
||||
/>
|
||||
</StyledTooltipHostBase>
|
||||
<StyledTooltipHostBase
|
||||
|
||||
@@ -2,7 +2,9 @@ import * as React from "react";
|
||||
import { JunoClient } from "../../../Juno/JunoClient";
|
||||
import { HttpStatusCodes, CodeOfConductEndpoints } from "../../../Common/Constants";
|
||||
import { Stack, Text, Checkbox, PrimaryButton, Link } from "office-ui-fabric-react";
|
||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { getErrorMessage, getErrorStack, handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { trace, traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
|
||||
export interface CodeOfConductComponentProps {
|
||||
junoClient: JunoClient;
|
||||
@@ -14,11 +16,11 @@ interface CodeOfConductComponentState {
|
||||
}
|
||||
|
||||
export class CodeOfConductComponent extends React.Component<CodeOfConductComponentProps, CodeOfConductComponentState> {
|
||||
private viewCodeOfConductTraced: boolean;
|
||||
private descriptionPara1: string;
|
||||
private descriptionPara2: string;
|
||||
private descriptionPara3: string;
|
||||
private link1: { label: string; url: string };
|
||||
private link2: { label: string; url: string };
|
||||
|
||||
constructor(props: CodeOfConductComponentProps) {
|
||||
super(props);
|
||||
@@ -27,23 +29,34 @@ export class CodeOfConductComponent extends React.Component<CodeOfConductCompone
|
||||
readCodeOfConduct: false,
|
||||
};
|
||||
|
||||
this.descriptionPara1 = "Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement";
|
||||
this.descriptionPara2 =
|
||||
"Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB.";
|
||||
this.descriptionPara3 = "In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the ";
|
||||
this.link1 = { label: "code of conduct", url: CodeOfConductEndpoints.codeOfConduct };
|
||||
this.link2 = { label: "privacy statement", url: CodeOfConductEndpoints.privacyStatement };
|
||||
this.descriptionPara1 = "Azure Cosmos DB Notebook Gallery - Code of Conduct";
|
||||
this.descriptionPara2 = "The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.";
|
||||
this.descriptionPara3 = "In order to view and publish your samples to the gallery, you must accept the ";
|
||||
this.link1 = { label: "code of conduct.", url: CodeOfConductEndpoints.codeOfConduct };
|
||||
}
|
||||
|
||||
private async acceptCodeOfConduct(): Promise<void> {
|
||||
const startKey = traceStart(Action.NotebooksGalleryAcceptCodeOfConduct);
|
||||
|
||||
try {
|
||||
const response = await this.props.junoClient.acceptCodeOfConduct();
|
||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
||||
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
|
||||
}
|
||||
|
||||
traceSuccess(Action.NotebooksGalleryAcceptCodeOfConduct, startKey);
|
||||
|
||||
this.props.onAcceptCodeOfConduct(response.data);
|
||||
} catch (error) {
|
||||
traceFailure(
|
||||
Action.NotebooksGalleryAcceptCodeOfConduct,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
handleError(error, "CodeOfConductComponent/acceptCodeOfConduct", "Failed to accept code of conduct");
|
||||
}
|
||||
}
|
||||
@@ -53,6 +66,11 @@ export class CodeOfConductComponent extends React.Component<CodeOfConductCompone
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
if (!this.viewCodeOfConductTraced) {
|
||||
this.viewCodeOfConductTraced = true;
|
||||
trace(Action.NotebooksGalleryViewCodeOfConduct);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 20 }}>
|
||||
<Stack.Item>
|
||||
@@ -69,10 +87,6 @@ export class CodeOfConductComponent extends React.Component<CodeOfConductCompone
|
||||
<Link href={this.link1.url} target="_blank">
|
||||
{this.link1.label}
|
||||
</Link>
|
||||
{" and "}
|
||||
<Link href={this.link2.url} target="_blank">
|
||||
{this.link2.label}
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
|
||||
@@ -87,7 +101,7 @@ export class CodeOfConductComponent extends React.Component<CodeOfConductCompone
|
||||
fontSize: 12,
|
||||
},
|
||||
}}
|
||||
label="I have read and accepted the code of conduct and privacy statement"
|
||||
label="I have read and accept the code of conduct."
|
||||
onChange={this.onChangeCheckbox}
|
||||
/>
|
||||
</Stack.Item>
|
||||
|
||||
@@ -7,6 +7,7 @@ import Explorer from "../../Explorer";
|
||||
|
||||
export interface GalleryAndNotebookViewerComponentProps {
|
||||
container?: Explorer;
|
||||
isGalleryPublishEnabled: boolean;
|
||||
junoClient: JunoClient;
|
||||
notebookUrl?: string;
|
||||
galleryItem?: IGalleryItem;
|
||||
@@ -60,6 +61,7 @@ export class GalleryAndNotebookViewerComponent extends React.Component<
|
||||
|
||||
const props: GalleryViewerComponentProps = {
|
||||
container: this.props.container,
|
||||
isGalleryPublishEnabled: this.props.isGalleryPublishEnabled,
|
||||
junoClient: this.props.junoClient,
|
||||
selectedTab: this.state.selectedTab,
|
||||
sortBy: this.state.sortBy,
|
||||
|
||||
@@ -7,14 +7,20 @@ import {
|
||||
} from "./GalleryAndNotebookViewerComponent";
|
||||
|
||||
export class GalleryAndNotebookViewerComponentAdapter implements ReactAdapter {
|
||||
private key: string;
|
||||
public parameters: ko.Observable<number>;
|
||||
|
||||
constructor(private props: GalleryAndNotebookViewerComponentProps) {
|
||||
this.reset();
|
||||
this.parameters = ko.observable<number>(Date.now());
|
||||
}
|
||||
|
||||
public renderComponent(): JSX.Element {
|
||||
return <GalleryAndNotebookViewerComponent {...this.props} />;
|
||||
return <GalleryAndNotebookViewerComponent key={this.key} {...this.props} />;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.key = `GalleryAndNotebookViewerComponent-${Date.now()}`;
|
||||
}
|
||||
|
||||
public triggerRender(): void {
|
||||
|
||||
@@ -6,4 +6,16 @@
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
font-family: @DataExplorerFont;
|
||||
background: @GalleryBackgroundColor;
|
||||
}
|
||||
|
||||
.publicGalleryTabContainer {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.publicGalleryTabOverlayContent {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
margin: 10%;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GalleryViewerComponent, GalleryViewerComponentProps, GalleryTab, SortBy
|
||||
describe("GalleryViewerComponent", () => {
|
||||
it("renders", () => {
|
||||
const props: GalleryViewerComponentProps = {
|
||||
isGalleryPublishEnabled: false,
|
||||
junoClient: undefined,
|
||||
selectedTab: GalleryTab.OfficialSamples,
|
||||
sortBy: SortBy.MostViewed,
|
||||
|
||||
@@ -9,10 +9,14 @@ import {
|
||||
IPivotProps,
|
||||
IRectangle,
|
||||
Label,
|
||||
Link,
|
||||
List,
|
||||
Overlay,
|
||||
Pivot,
|
||||
PivotItem,
|
||||
SearchBox,
|
||||
Spinner,
|
||||
SpinnerSize,
|
||||
Stack,
|
||||
Text,
|
||||
} from "office-ui-fabric-react";
|
||||
@@ -27,9 +31,12 @@ import Explorer from "../../Explorer";
|
||||
import { CodeOfConductComponent } from "./CodeOfConductComponent";
|
||||
import { InfoComponent } from "./InfoComponent/InfoComponent";
|
||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { trace } from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
|
||||
export interface GalleryViewerComponentProps {
|
||||
container?: Explorer;
|
||||
isGalleryPublishEnabled: boolean;
|
||||
junoClient: JunoClient;
|
||||
selectedTab: GalleryTab;
|
||||
sortBy: SortBy;
|
||||
@@ -64,6 +71,8 @@ interface GalleryViewerComponentState {
|
||||
searchText: string;
|
||||
dialogProps: DialogProps;
|
||||
isCodeOfConductAccepted: boolean;
|
||||
isFetchingPublishedNotebooks: boolean;
|
||||
isFetchingFavouriteNotebooks: boolean;
|
||||
}
|
||||
|
||||
interface GalleryTabInfo {
|
||||
@@ -74,18 +83,24 @@ interface GalleryTabInfo {
|
||||
export class GalleryViewerComponent extends React.Component<GalleryViewerComponentProps, GalleryViewerComponentState> {
|
||||
public static readonly OfficialSamplesTitle = "Official samples";
|
||||
public static readonly PublicGalleryTitle = "Public gallery";
|
||||
public static readonly FavoritesTitle = "Liked";
|
||||
public static readonly PublishedTitle = "Your published work";
|
||||
public static readonly FavoritesTitle = "My favorites";
|
||||
public static readonly PublishedTitle = "My published work";
|
||||
|
||||
private static readonly rowsPerPage = 5;
|
||||
|
||||
private static readonly mostViewedText = "Most viewed";
|
||||
private static readonly mostDownloadedText = "Most downloaded";
|
||||
private static readonly mostFavoritedText = "Most liked";
|
||||
private static readonly mostFavoritedText = "Most favorited";
|
||||
private static readonly mostRecentText = "Most recent";
|
||||
|
||||
private readonly sortingOptions: IDropdownOption[];
|
||||
|
||||
private viewGalleryTraced: boolean;
|
||||
private viewOfficialSamplesTraced: boolean;
|
||||
private viewPublicGalleryTraced: boolean;
|
||||
private viewFavoritesTraced: boolean;
|
||||
private viewPublishedNotebooksTraced: boolean;
|
||||
|
||||
private sampleNotebooks: IGalleryItem[];
|
||||
private publicNotebooks: IGalleryItem[];
|
||||
private favoriteNotebooks: IGalleryItem[];
|
||||
@@ -107,6 +122,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
searchText: props.searchText,
|
||||
dialogProps: undefined,
|
||||
isCodeOfConductAccepted: undefined,
|
||||
isFetchingFavouriteNotebooks: true,
|
||||
isFetchingPublishedNotebooks: true,
|
||||
};
|
||||
|
||||
this.sortingOptions = [
|
||||
@@ -137,9 +154,11 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
this.traceViewGallery();
|
||||
|
||||
const tabs: GalleryTabInfo[] = [this.createSamplesTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
|
||||
|
||||
if (this.props.container?.isGalleryPublishEnabled()) {
|
||||
if (this.props.isGalleryPublishEnabled) {
|
||||
tabs.push(
|
||||
this.createPublicGalleryTab(
|
||||
GalleryTab.PublicGallery,
|
||||
@@ -147,13 +166,11 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
this.state.isCodeOfConductAccepted
|
||||
)
|
||||
);
|
||||
tabs.push(this.createFavoritesTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
|
||||
}
|
||||
|
||||
// explicitly checking if isCodeOfConductAccepted is not false, as it is initially undefined.
|
||||
// Displaying code of conduct component on gallery load should not be the default behavior.
|
||||
if (this.state.isCodeOfConductAccepted !== false) {
|
||||
tabs.push(this.createPublishedNotebooksTab(GalleryTab.Published, this.state.publishedNotebooks));
|
||||
}
|
||||
if (this.props.container?.isGalleryPublishEnabled()) {
|
||||
tabs.push(this.createFavoritesTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
|
||||
tabs.push(this.createPublishedNotebooksTab(GalleryTab.Published, this.state.publishedNotebooks));
|
||||
}
|
||||
|
||||
const pivotProps: IPivotProps = {
|
||||
@@ -184,11 +201,58 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
);
|
||||
}
|
||||
|
||||
private traceViewGallery = (): void => {
|
||||
if (!this.viewGalleryTraced) {
|
||||
this.viewGalleryTraced = true;
|
||||
trace(Action.NotebooksGalleryViewGallery);
|
||||
}
|
||||
|
||||
switch (this.state.selectedTab) {
|
||||
case GalleryTab.OfficialSamples:
|
||||
if (!this.viewOfficialSamplesTraced) {
|
||||
this.resetViewGalleryTabTracedFlags();
|
||||
this.viewOfficialSamplesTraced = true;
|
||||
trace(Action.NotebooksGalleryViewOfficialSamples);
|
||||
}
|
||||
break;
|
||||
case GalleryTab.PublicGallery:
|
||||
if (!this.viewPublicGalleryTraced) {
|
||||
this.resetViewGalleryTabTracedFlags();
|
||||
this.viewPublicGalleryTraced = true;
|
||||
trace(Action.NotebooksGalleryViewPublicGallery);
|
||||
}
|
||||
break;
|
||||
case GalleryTab.Favorites:
|
||||
if (!this.viewFavoritesTraced) {
|
||||
this.resetViewGalleryTabTracedFlags();
|
||||
this.viewFavoritesTraced = true;
|
||||
trace(Action.NotebooksGalleryViewFavorites);
|
||||
}
|
||||
break;
|
||||
case GalleryTab.Published:
|
||||
if (!this.viewPublishedNotebooksTraced) {
|
||||
this.resetViewGalleryTabTracedFlags();
|
||||
this.viewPublishedNotebooksTraced = true;
|
||||
trace(Action.NotebooksGalleryViewPublishedNotebooks);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown selected tab ${this.state.selectedTab}`);
|
||||
}
|
||||
};
|
||||
|
||||
private resetViewGalleryTabTracedFlags = (): void => {
|
||||
this.viewOfficialSamplesTraced = false;
|
||||
this.viewPublicGalleryTraced = false;
|
||||
this.viewFavoritesTraced = false;
|
||||
this.viewPublishedNotebooksTraced = false;
|
||||
};
|
||||
|
||||
private isEmptyData = (data: IGalleryItem[]): boolean => {
|
||||
return !data || data.length === 0;
|
||||
};
|
||||
|
||||
private createEmptyTabContent = (iconName: string, line1: string, line2: string): JSX.Element => {
|
||||
private createEmptyTabContent = (iconName: string, line1: JSX.Element, line2: JSX.Element): JSX.Element => {
|
||||
return (
|
||||
<Stack horizontalAlign="center" tokens={{ childrenGap: 10 }}>
|
||||
<FontIcon iconName={iconName} style={{ fontSize: 100, color: "lightgray", marginTop: 20 }} />
|
||||
@@ -216,40 +280,63 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
};
|
||||
}
|
||||
|
||||
private getFavouriteNotebooksTabContent = (data: IGalleryItem[]) => {
|
||||
if (this.isEmptyData(data)) {
|
||||
if (this.state.isFetchingFavouriteNotebooks) {
|
||||
return <Spinner size={SpinnerSize.large} />;
|
||||
}
|
||||
return this.createEmptyTabContent(
|
||||
"ContactHeart",
|
||||
<>You don't have any favorites yet</>,
|
||||
<>
|
||||
Favorite any notebook from the{" "}
|
||||
<Link onClick={() => this.setState({ selectedTab: GalleryTab.OfficialSamples })}>official samples</Link> or{" "}
|
||||
<Link onClick={() => this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return this.createSearchBarHeader(this.createCardsTabContent(data));
|
||||
};
|
||||
|
||||
private createFavoritesTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo {
|
||||
return {
|
||||
tab,
|
||||
content: this.isEmptyData(data)
|
||||
? this.createEmptyTabContent(
|
||||
"ContactHeart",
|
||||
"You have not liked anything",
|
||||
"Like any notebook from Official Samples or Public gallery"
|
||||
)
|
||||
: this.createSearchBarHeader(this.createCardsTabContent(data)),
|
||||
content: this.getFavouriteNotebooksTabContent(data),
|
||||
};
|
||||
}
|
||||
|
||||
private getPublishedNotebooksTabContent = (data: IGalleryItem[]) => {
|
||||
if (this.isEmptyData(data)) {
|
||||
if (this.state.isFetchingPublishedNotebooks) {
|
||||
return <Spinner size={SpinnerSize.large} />;
|
||||
}
|
||||
return this.createEmptyTabContent(
|
||||
"Contact",
|
||||
<>
|
||||
You have not published anything to the{" "}
|
||||
<Link onClick={() => this.setState({ selectedTab: GalleryTab.PublicGallery })}>public gallery</Link> yet
|
||||
</>,
|
||||
<>Publish your notebooks to share your work with other users</>
|
||||
);
|
||||
}
|
||||
return this.createPublishedNotebooksTabContent(data);
|
||||
};
|
||||
|
||||
private createPublishedNotebooksTab = (tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo => {
|
||||
return {
|
||||
tab,
|
||||
content: this.isEmptyData(data)
|
||||
? this.createEmptyTabContent(
|
||||
"Contact",
|
||||
"You have not published anything",
|
||||
"Publish your sample notebooks to share your published work with others"
|
||||
)
|
||||
: this.createPublishedNotebooksTabContent(data),
|
||||
content: this.getPublishedNotebooksTabContent(data),
|
||||
};
|
||||
};
|
||||
|
||||
private createPublishedNotebooksTabContent = (data: IGalleryItem[]): JSX.Element => {
|
||||
const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(data);
|
||||
const content = (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack tokens={{ childrenGap: 20 }}>
|
||||
{published?.length > 0 &&
|
||||
this.createPublishedNotebooksSectionContent(
|
||||
undefined,
|
||||
"You have successfully published the following notebook(s) to public gallery and shared with other Azure Cosmos DB users.",
|
||||
"You have successfully published and shared the following notebook(s) to the public gallery.",
|
||||
this.createCardsTabContent(published)
|
||||
)}
|
||||
{underReview?.length > 0 &&
|
||||
@@ -276,24 +363,33 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
content: JSX.Element
|
||||
): JSX.Element => {
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
{title && <Text styles={{ root: { fontWeight: FontWeights.semibold } }}>{title}</Text>}
|
||||
{description && <Text>{description}</Text>}
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
{title && (
|
||||
<Text styles={{ root: { fontWeight: FontWeights.semibold, marginLeft: 10, marginRight: 10 } }}>{title}</Text>
|
||||
)}
|
||||
{description && <Text styles={{ root: { marginLeft: 10, marginRight: 10 } }}>{description}</Text>}
|
||||
{content}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element {
|
||||
return acceptedCodeOfConduct === false ? (
|
||||
<CodeOfConductComponent
|
||||
junoClient={this.props.junoClient}
|
||||
onAcceptCodeOfConduct={(result: boolean) => {
|
||||
this.setState({ isCodeOfConductAccepted: result });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
this.createSearchBarHeader(this.createCardsTabContent(data))
|
||||
return (
|
||||
<div className="publicGalleryTabContainer">
|
||||
{this.createSearchBarHeader(this.createCardsTabContent(data))}
|
||||
{acceptedCodeOfConduct === false && (
|
||||
<Overlay isDarkThemed>
|
||||
<div className="publicGalleryTabOverlayContent">
|
||||
<CodeOfConductComponent
|
||||
junoClient={this.props.junoClient}
|
||||
onAcceptCodeOfConduct={(result: boolean) => {
|
||||
this.setState({ isCodeOfConductAccepted: result });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +406,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
<Stack.Item styles={{ root: { minWidth: 200 } }}>
|
||||
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
|
||||
</Stack.Item>
|
||||
{(!this.props.container || this.props.container.isGalleryPublishEnabled()) && (
|
||||
{this.props.isGalleryPublishEnabled && (
|
||||
<Stack.Item>
|
||||
<InfoComponent />
|
||||
</Stack.Item>
|
||||
@@ -322,7 +418,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
}
|
||||
|
||||
private createCardsTabContent(data: IGalleryItem[]): JSX.Element {
|
||||
return (
|
||||
return data ? (
|
||||
<FocusZone>
|
||||
<List
|
||||
items={data}
|
||||
@@ -331,12 +427,14 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
onRenderCell={this.onRenderCell}
|
||||
/>
|
||||
</FocusZone>
|
||||
) : (
|
||||
<Spinner size={SpinnerSize.large} />
|
||||
);
|
||||
}
|
||||
|
||||
private createPolicyViolationsListContent(data: IGalleryItem[]): JSX.Element {
|
||||
return (
|
||||
<table>
|
||||
<table style={{ margin: 10 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -385,6 +483,10 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
}
|
||||
|
||||
this.sampleNotebooks = response.data;
|
||||
|
||||
trace(Action.NotebooksGalleryOfficialSamplesCount, ActionModifiers.Mark, {
|
||||
count: this.sampleNotebooks?.length,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, "GalleryViewerComponent/loadSampleNotebooks", "Failed to load sample notebooks");
|
||||
}
|
||||
@@ -411,6 +513,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
||||
throw new Error(`Received HTTP ${response.status} when loading public notebooks`);
|
||||
}
|
||||
|
||||
trace(Action.NotebooksGalleryPublicGalleryCount, ActionModifiers.Mark, { count: this.publicNotebooks?.length });
|
||||
} catch (error) {
|
||||
handleError(error, "GalleryViewerComponent/loadPublicNotebooks", "Failed to load public notebooks");
|
||||
}
|
||||
@@ -425,14 +529,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
private async loadFavoriteNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
|
||||
if (!offline) {
|
||||
try {
|
||||
this.setState({ isFetchingFavouriteNotebooks: true });
|
||||
const response = await this.props.junoClient.getFavoriteNotebooks();
|
||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
||||
throw new Error(`Received HTTP ${response.status} when loading favorite notebooks`);
|
||||
}
|
||||
|
||||
this.favoriteNotebooks = response.data;
|
||||
|
||||
trace(Action.NotebooksGalleryFavoritesCount, ActionModifiers.Mark, { count: this.favoriteNotebooks?.length });
|
||||
} catch (error) {
|
||||
handleError(error, "GalleryViewerComponent/loadFavoriteNotebooks", "Failed to load favorite notebooks");
|
||||
} finally {
|
||||
this.setState({ isFetchingFavouriteNotebooks: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,14 +560,25 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
||||
private async loadPublishedNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
|
||||
if (!offline) {
|
||||
try {
|
||||
this.setState({ isFetchingPublishedNotebooks: true });
|
||||
const response = await this.props.junoClient.getPublishedNotebooks();
|
||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
||||
throw new Error(`Received HTTP ${response.status} when loading published notebooks`);
|
||||
}
|
||||
|
||||
this.publishedNotebooks = response.data;
|
||||
|
||||
const { published, underReview, removed } = GalleryUtils.filterPublishedNotebooks(this.publishedNotebooks);
|
||||
trace(Action.NotebooksGalleryPublishedCount, ActionModifiers.Mark, {
|
||||
count: this.publishedNotebooks?.length,
|
||||
publishedCount: published.length,
|
||||
underReviewCount: underReview.length,
|
||||
removedCount: removed.length,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, "GalleryViewerComponent/loadPublishedNotebooks", "Failed to load published notebooks");
|
||||
} finally {
|
||||
this.setState({ isFetchingPublishedNotebooks: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,35 +17,28 @@ exports[`CodeOfConductComponent renders 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement
|
||||
Azure Cosmos DB Notebook Gallery - Code of Conduct
|
||||
</Text>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
<Text>
|
||||
Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB.
|
||||
The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.
|
||||
</Text>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
<Text>
|
||||
In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the
|
||||
In order to view and publish your samples to the gallery, you must accept the
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/cosmos-code-of-conduct"
|
||||
target="_blank"
|
||||
>
|
||||
code of conduct
|
||||
</StyledLinkBase>
|
||||
and
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/ms-privacy-policy"
|
||||
target="_blank"
|
||||
>
|
||||
privacy statement
|
||||
code of conduct.
|
||||
</StyledLinkBase>
|
||||
</Text>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
<StyledCheckboxBase
|
||||
label="I have read and accepted the code of conduct and privacy statement"
|
||||
label="I have read and accept the code of conduct."
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
Object {
|
||||
|
||||
@@ -77,24 +77,11 @@ exports[`GalleryViewerComponent renders 1`] = `
|
||||
selectedKey={0}
|
||||
/>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
<InfoComponent />
|
||||
</StackItem>
|
||||
</Stack>
|
||||
<StackItem>
|
||||
<FocusZone
|
||||
direction={2}
|
||||
isCircularNavigation={false}
|
||||
shouldRaiseClicks={true}
|
||||
>
|
||||
<List
|
||||
getPageSpecification={[Function]}
|
||||
onRenderCell={[Function]}
|
||||
renderedWindowsAhead={3}
|
||||
renderedWindowsBehind={2}
|
||||
startIndex={0}
|
||||
/>
|
||||
</FocusZone>
|
||||
<StyledSpinnerBase
|
||||
size={3}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</PivotItem>
|
||||
|
||||
@@ -31,6 +31,26 @@ export interface NotebookMetadataComponentProps {
|
||||
}
|
||||
|
||||
export class NotebookMetadataComponent extends React.Component<NotebookMetadataComponentProps> {
|
||||
private renderFavouriteButton = (): JSX.Element => {
|
||||
return (
|
||||
<Text>
|
||||
{this.props.isFavorite !== undefined ? (
|
||||
<>
|
||||
<IconButton
|
||||
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
|
||||
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
|
||||
/>
|
||||
{this.props.data.favorites} likes
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon iconName="Heart" /> {this.props.data.favorites} likes
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
@@ -49,19 +69,7 @@ export class NotebookMetadataComponent extends React.Component<NotebookMetadataC
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
|
||||
<Stack.Item>
|
||||
<Text>
|
||||
{this.props.isFavorite !== undefined && (
|
||||
<>
|
||||
<IconButton
|
||||
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
|
||||
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
|
||||
/>
|
||||
{this.props.data.favorites} likes
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
<Stack.Item>{this.renderFavouriteButton()}</Stack.Item>
|
||||
|
||||
{this.props.downloadButtonText && (
|
||||
<Stack.Item>
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
*/
|
||||
import { Notebook } from "@nteract/commutable";
|
||||
import { createContentRef } from "@nteract/core";
|
||||
import { IChoiceGroupProps, Icon, Link, ProgressIndicator } from "office-ui-fabric-react";
|
||||
import { IChoiceGroupProps, Icon, IProgressIndicatorProps, Link, ProgressIndicator } from "office-ui-fabric-react";
|
||||
import * as React from "react";
|
||||
import { contents } from "rx-jupyter";
|
||||
import * as Logger from "../../../Common/Logger";
|
||||
import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient";
|
||||
import * as GalleryUtils from "../../../Utils/GalleryUtils";
|
||||
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
|
||||
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { NotebookClientV2 } from "../../Notebook/NotebookClientV2";
|
||||
import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper";
|
||||
import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer";
|
||||
@@ -21,7 +18,9 @@ import Explorer from "../../Explorer";
|
||||
import { NotebookV4 } from "@nteract/commutable/lib/v4";
|
||||
import { SessionStorageUtility } from "../../../Shared/StorageUtility";
|
||||
import { DialogHost } from "../../../Utils/GalleryUtils";
|
||||
import { getErrorMessage, handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { getErrorMessage, getErrorStack, handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
|
||||
export interface NotebookViewerComponentProps {
|
||||
container?: Explorer;
|
||||
@@ -80,6 +79,12 @@ export class NotebookViewerComponent
|
||||
}
|
||||
|
||||
private async loadNotebookContent(): Promise<void> {
|
||||
const startKey = traceStart(Action.NotebooksGalleryViewNotebook, {
|
||||
notebookUrl: this.props.notebookUrl,
|
||||
notebookId: this.props.galleryItem?.id,
|
||||
isSample: this.props.galleryItem?.isSample,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(this.props.notebookUrl);
|
||||
if (!response.ok) {
|
||||
@@ -87,6 +92,16 @@ export class NotebookViewerComponent
|
||||
throw new Error(`Received HTTP ${response.status} while fetching ${this.props.notebookUrl}`);
|
||||
}
|
||||
|
||||
traceSuccess(
|
||||
Action.NotebooksGalleryViewNotebook,
|
||||
{
|
||||
notebookUrl: this.props.notebookUrl,
|
||||
notebookId: this.props.galleryItem?.id,
|
||||
isSample: this.props.galleryItem?.isSample,
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
const notebook: Notebook = await response.json();
|
||||
this.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId);
|
||||
this.notebookComponentBootstrapper.setContent("json", notebook);
|
||||
@@ -101,6 +116,18 @@ export class NotebookViewerComponent
|
||||
SessionStorageUtility.setEntry(this.props.galleryItem?.id, "true");
|
||||
}
|
||||
} catch (error) {
|
||||
traceFailure(
|
||||
Action.NotebooksGalleryViewNotebook,
|
||||
{
|
||||
notebookUrl: this.props.notebookUrl,
|
||||
notebookId: this.props.galleryItem?.id,
|
||||
isSample: this.props.galleryItem?.isSample,
|
||||
error: getErrorMessage(error),
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
this.setState({ showProgressBar: false });
|
||||
handleError(error, "NotebookViewerComponent/loadNotebookContent", "Failed to load notebook content");
|
||||
}
|
||||
@@ -178,6 +205,32 @@ export class NotebookViewerComponent
|
||||
};
|
||||
}
|
||||
|
||||
// DialogHost
|
||||
showOkModalDialog(
|
||||
title: string,
|
||||
msg: string,
|
||||
okLabel: string,
|
||||
onOk: () => void,
|
||||
progressIndicatorProps?: IProgressIndicatorProps
|
||||
): void {
|
||||
this.setState({
|
||||
dialogProps: {
|
||||
isModal: true,
|
||||
visible: true,
|
||||
title,
|
||||
subText: msg,
|
||||
primaryButtonText: okLabel,
|
||||
onPrimaryButtonClick: () => {
|
||||
this.setState({ dialogProps: undefined });
|
||||
onOk && onOk();
|
||||
},
|
||||
secondaryButtonText: undefined,
|
||||
onSecondaryButtonClick: undefined,
|
||||
progressIndicatorProps,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// DialogHost
|
||||
showOkCancelModalDialog(
|
||||
title: string,
|
||||
@@ -186,8 +239,10 @@ export class NotebookViewerComponent
|
||||
onOk: () => void,
|
||||
cancelLabel: string,
|
||||
onCancel: () => void,
|
||||
progressIndicatorProps?: IProgressIndicatorProps,
|
||||
choiceGroupProps?: IChoiceGroupProps,
|
||||
textFieldProps?: TextFieldProps
|
||||
textFieldProps?: TextFieldProps,
|
||||
primaryButtonDisabled?: boolean
|
||||
): void {
|
||||
this.setState({
|
||||
dialogProps: {
|
||||
@@ -205,8 +260,10 @@ export class NotebookViewerComponent
|
||||
this.setState({ dialogProps: undefined });
|
||||
onCancel && onCancel();
|
||||
},
|
||||
progressIndicatorProps,
|
||||
choiceGroupProps,
|
||||
textFieldProps,
|
||||
primaryButtonDisabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
import { SettingsComponentProps, SettingsComponent, SettingsComponentState } from "./SettingsComponent";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import SettingsTabV2 from "../../Tabs/SettingsTabV2";
|
||||
import { CollectionSettingsTabV2 } from "../../Tabs/SettingsTabV2";
|
||||
import { collection } from "./TestUtils";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import ko from "knockout";
|
||||
@@ -31,25 +31,21 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
|
||||
}));
|
||||
import { updateOffer } from "../../../Common/dataAccess/updateOffer";
|
||||
import { MongoDBCollectionResource } from "../../../Utils/arm/generatedClients/2020-04-01/types";
|
||||
import Q from "q";
|
||||
jest.mock("../../../Common/dataAccess/updateOffer", () => ({
|
||||
updateOffer: jest.fn().mockReturnValue({} as DataModels.Offer),
|
||||
}));
|
||||
|
||||
describe("SettingsComponent", () => {
|
||||
const baseProps: SettingsComponentProps = {
|
||||
settingsTab: new SettingsTabV2({
|
||||
settingsTab: new CollectionSettingsTabV2({
|
||||
collection: collection,
|
||||
tabKind: ViewModels.CollectionTabKind.SettingsV2,
|
||||
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
node: undefined,
|
||||
hashLocation: "settings",
|
||||
isActive: ko.observable(false),
|
||||
onUpdateTabsButtons: undefined,
|
||||
getPendingNotification: Q.Promise<DataModels.Notification>(() => {
|
||||
return;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -142,6 +138,7 @@ describe("SettingsComponent", () => {
|
||||
readSettings: undefined,
|
||||
onSettingsClick: undefined,
|
||||
loadOffer: undefined,
|
||||
getPendingThroughputSplitNotification: undefined,
|
||||
} as ViewModels.Database;
|
||||
newCollection.getDatabase = () => newDatabase;
|
||||
newCollection.offer = ko.observable(undefined);
|
||||
|
||||
@@ -11,7 +11,7 @@ import Explorer from "../../Explorer";
|
||||
import { updateOffer } from "../../../Common/dataAccess/updateOffer";
|
||||
import { updateCollection, updateMongoDBCollectionThroughRP } from "../../../Common/dataAccess/updateCollection";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import SettingsTab from "../../Tabs/SettingsTabV2";
|
||||
import { SettingsTabV2 } from "../../Tabs/SettingsTabV2";
|
||||
import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils";
|
||||
import { ScaleComponent, ScaleComponentProps } from "./SettingsSubComponents/ScaleComponent";
|
||||
import {
|
||||
@@ -58,7 +58,7 @@ interface ButtonV2 {
|
||||
}
|
||||
|
||||
export interface SettingsComponentProps {
|
||||
settingsTab: SettingsTab;
|
||||
settingsTab: SettingsTabV2;
|
||||
}
|
||||
|
||||
export interface SettingsComponentState {
|
||||
@@ -116,7 +116,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
private discardSettingsChangesButton: ButtonV2;
|
||||
|
||||
private isAnalyticalStorageEnabled: boolean;
|
||||
private isCollectionSettingsTab: boolean;
|
||||
private collection: ViewModels.Collection;
|
||||
private database: ViewModels.Database;
|
||||
private offer: DataModels.Offer;
|
||||
private container: Explorer;
|
||||
private changeFeedPolicyVisible: boolean;
|
||||
private isFixedContainer: boolean;
|
||||
@@ -126,20 +129,28 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
constructor(props: SettingsComponentProps) {
|
||||
super(props);
|
||||
|
||||
this.collection = this.props.settingsTab.collection as ViewModels.Collection;
|
||||
this.container = this.collection?.container;
|
||||
this.isAnalyticalStorageEnabled = !!this.collection?.analyticalStorageTtl();
|
||||
this.shouldShowIndexingPolicyEditor =
|
||||
this.container && !this.container.isPreferredApiCassandra() && !this.container.isPreferredApiMongoDB();
|
||||
this.isCollectionSettingsTab = this.props.settingsTab.tabKind === ViewModels.CollectionTabKind.CollectionSettingsV2;
|
||||
if (this.isCollectionSettingsTab) {
|
||||
this.collection = this.props.settingsTab.collection as ViewModels.Collection;
|
||||
this.container = this.collection?.container;
|
||||
this.offer = this.collection?.offer();
|
||||
this.isAnalyticalStorageEnabled = !!this.collection?.analyticalStorageTtl();
|
||||
this.shouldShowIndexingPolicyEditor =
|
||||
this.container && !this.container.isPreferredApiCassandra() && !this.container.isPreferredApiMongoDB();
|
||||
|
||||
this.changeFeedPolicyVisible = this.collection?.container.isFeatureEnabled(
|
||||
Constants.Features.enableChangeFeedPolicy
|
||||
);
|
||||
this.changeFeedPolicyVisible = this.collection?.container.isFeatureEnabled(
|
||||
Constants.Features.enableChangeFeedPolicy
|
||||
);
|
||||
|
||||
// Mongo container with system partition key still treat as "Fixed"
|
||||
this.isFixedContainer =
|
||||
this.container.isPreferredApiMongoDB() &&
|
||||
(!this.collection.partitionKey || this.collection.partitionKey.systemKey);
|
||||
// Mongo container with system partition key still treat as "Fixed"
|
||||
this.isFixedContainer =
|
||||
this.container.isPreferredApiMongoDB() &&
|
||||
(!this.collection?.partitionKey || this.collection?.partitionKey.systemKey);
|
||||
} else {
|
||||
this.database = this.props.settingsTab.database;
|
||||
this.container = this.database?.container;
|
||||
this.offer = this.database?.offer();
|
||||
}
|
||||
|
||||
this.state = {
|
||||
throughput: undefined,
|
||||
@@ -206,18 +217,21 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.refreshIndexTransformationProgress();
|
||||
this.loadMongoIndexes();
|
||||
if (this.isCollectionSettingsTab) {
|
||||
this.refreshIndexTransformationProgress();
|
||||
this.loadMongoIndexes();
|
||||
}
|
||||
|
||||
this.setAutoPilotStates();
|
||||
this.setBaseline();
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
this.props.settingsTab.getSettingsTabContainer().onUpdateTabsButtons(this.getTabsButtons());
|
||||
this.props.settingsTab.getContainer().onUpdateTabsButtons(this.getTabsButtons());
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(): void {
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
this.props.settingsTab.getSettingsTabContainer().onUpdateTabsButtons(this.getTabsButtons());
|
||||
this.props.settingsTab.getContainer().onUpdateTabsButtons(this.getTabsButtons());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +284,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
};
|
||||
|
||||
private setAutoPilotStates = (): void => {
|
||||
const autoscaleMaxThroughput = this.collection?.offer()?.autoscaleMaxThroughput;
|
||||
const autoscaleMaxThroughput = this.offer?.autoscaleMaxThroughput;
|
||||
|
||||
if (autoscaleMaxThroughput && AutoPilotUtils.isValidAutoPilotThroughput(autoscaleMaxThroughput)) {
|
||||
this.setState({
|
||||
@@ -295,7 +309,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
!!this.collection.conflictResolutionPolicy();
|
||||
|
||||
public isOfferReplacePending = (): boolean => {
|
||||
return this.collection?.offer()?.offerReplacePending;
|
||||
return this.offer?.offerReplacePending;
|
||||
};
|
||||
|
||||
public onSaveClick = async (): Promise<void> => {
|
||||
@@ -309,174 +323,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
tabTitle: this.props.settingsTab.tabTitle(),
|
||||
});
|
||||
|
||||
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
|
||||
|
||||
try {
|
||||
if (
|
||||
this.state.isSubSettingsSaveable ||
|
||||
this.state.isIndexingPolicyDirty ||
|
||||
this.state.isConflictResolutionDirty
|
||||
) {
|
||||
let defaultTtl: number;
|
||||
switch (this.state.timeToLive) {
|
||||
case TtlType.On:
|
||||
defaultTtl = Number(this.state.timeToLiveSeconds);
|
||||
break;
|
||||
case TtlType.OnNoDefault:
|
||||
defaultTtl = -1;
|
||||
break;
|
||||
case TtlType.Off:
|
||||
default:
|
||||
defaultTtl = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
const wasIndexingPolicyModified = this.state.isIndexingPolicyDirty;
|
||||
newCollection.defaultTtl = defaultTtl;
|
||||
|
||||
newCollection.indexingPolicy = this.state.indexingPolicyContent;
|
||||
|
||||
newCollection.changeFeedPolicy =
|
||||
this.changeFeedPolicyVisible && this.state.changeFeedPolicy === ChangeFeedPolicyState.On
|
||||
? {
|
||||
retentionDuration: Constants.BackendDefaults.maxChangeFeedRetentionDuration,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
newCollection.analyticalStorageTtl = this.getAnalyticalStorageTtl();
|
||||
|
||||
newCollection.geospatialConfig = {
|
||||
type: this.state.geospatialConfigType,
|
||||
};
|
||||
|
||||
const conflictResolutionChanges: DataModels.ConflictResolutionPolicy = this.getUpdatedConflictResolutionPolicy();
|
||||
if (conflictResolutionChanges) {
|
||||
newCollection.conflictResolutionPolicy = conflictResolutionChanges;
|
||||
}
|
||||
|
||||
const updatedCollection: DataModels.Collection = await updateCollection(
|
||||
this.collection.databaseId,
|
||||
this.collection.id(),
|
||||
newCollection
|
||||
);
|
||||
this.collection.rawDataModel = updatedCollection;
|
||||
this.collection.defaultTtl(updatedCollection.defaultTtl);
|
||||
this.collection.analyticalStorageTtl(updatedCollection.analyticalStorageTtl);
|
||||
this.collection.id(updatedCollection.id);
|
||||
this.collection.indexingPolicy(updatedCollection.indexingPolicy);
|
||||
this.collection.conflictResolutionPolicy(updatedCollection.conflictResolutionPolicy);
|
||||
this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy);
|
||||
this.collection.geospatialConfig(updatedCollection.geospatialConfig);
|
||||
|
||||
if (wasIndexingPolicyModified) {
|
||||
await this.refreshIndexTransformationProgress();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isSubSettingsSaveable: false,
|
||||
isSubSettingsDiscardable: false,
|
||||
isIndexingPolicyDirty: false,
|
||||
isConflictResolutionDirty: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.isMongoIndexingPolicySaveable && this.mongoDBCollectionResource) {
|
||||
try {
|
||||
const newMongoIndexes = this.getMongoIndexesToSave();
|
||||
const newMongoCollection: MongoDBCollectionResource = {
|
||||
...this.mongoDBCollectionResource,
|
||||
indexes: newMongoIndexes,
|
||||
};
|
||||
|
||||
this.mongoDBCollectionResource = await updateMongoDBCollectionThroughRP(
|
||||
this.collection.databaseId,
|
||||
this.collection.id(),
|
||||
newMongoCollection
|
||||
);
|
||||
|
||||
await this.refreshIndexTransformationProgress();
|
||||
this.setState({
|
||||
isMongoIndexingPolicySaveable: false,
|
||||
indexesToDrop: [],
|
||||
indexesToAdd: [],
|
||||
currentMongoIndexes: [...this.mongoDBCollectionResource.indexes],
|
||||
});
|
||||
traceSuccess(
|
||||
Action.MongoIndexUpdated,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
databaseName: this.collection?.databaseId,
|
||||
collectionName: this.collection?.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle(),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
} catch (error) {
|
||||
traceFailure(
|
||||
Action.MongoIndexUpdated,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
databaseName: this.collection?.databaseId,
|
||||
collectionName: this.collection?.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle(),
|
||||
error: getErrorMessage(error),
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.isScaleSaveable) {
|
||||
const updateOfferParams: DataModels.UpdateOfferParams = {
|
||||
databaseId: this.collection.databaseId,
|
||||
collectionId: this.collection.id(),
|
||||
currentOffer: this.collection.offer(),
|
||||
autopilotThroughput: this.state.isAutoPilotSelected ? this.state.autoPilotThroughput : undefined,
|
||||
manualThroughput: this.state.isAutoPilotSelected ? undefined : this.state.throughput,
|
||||
};
|
||||
if (this.hasProvisioningTypeChanged()) {
|
||||
if (this.state.isAutoPilotSelected) {
|
||||
updateOfferParams.migrateToAutoPilot = true;
|
||||
} else {
|
||||
updateOfferParams.migrateToManual = true;
|
||||
}
|
||||
}
|
||||
const updatedOffer: DataModels.Offer = await updateOffer(updateOfferParams);
|
||||
this.collection.offer(updatedOffer);
|
||||
this.setState({ isScaleSaveable: false, isScaleDiscardable: false });
|
||||
if (this.state.isAutoPilotSelected) {
|
||||
this.setState({
|
||||
autoPilotThroughput: updatedOffer.autoscaleMaxThroughput,
|
||||
autoPilotThroughputBaseline: updatedOffer.autoscaleMaxThroughput,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
throughput: updatedOffer.manualThroughput,
|
||||
throughputBaseline: updatedOffer.manualThroughput,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this.setBaseline();
|
||||
this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected });
|
||||
traceSuccess(
|
||||
Action.SettingsV2Updated,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
databaseName: this.collection?.databaseId,
|
||||
collectionName: this.collection?.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle(),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
await (this.isCollectionSettingsTab
|
||||
? this.saveCollectionSettings(startKey)
|
||||
: this.saveDatabaseSettings(startKey));
|
||||
} catch (error) {
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this.props.settingsTab.isExecutionError(true);
|
||||
@@ -495,8 +345,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
},
|
||||
startKey
|
||||
);
|
||||
} finally {
|
||||
this.props.settingsTab.isExecuting(false);
|
||||
}
|
||||
this.props.settingsTab.isExecuting(false);
|
||||
};
|
||||
|
||||
public onRevertClick = (): void => {
|
||||
@@ -693,6 +544,17 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
};
|
||||
|
||||
public setBaseline = (): void => {
|
||||
const offerThroughput = this.offer?.manualThroughput;
|
||||
|
||||
if (!this.isCollectionSettingsTab) {
|
||||
this.setState({
|
||||
throughput: offerThroughput,
|
||||
throughputBaseline: offerThroughput,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultTtl = this.collection.defaultTtl();
|
||||
|
||||
let timeToLive: TtlType = this.state.timeToLive;
|
||||
@@ -725,7 +587,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
}
|
||||
}
|
||||
|
||||
const offerThroughput = this.collection.offer()?.manualThroughput;
|
||||
const changeFeedPolicy = this.collection.rawDataModel?.changeFeedPolicy
|
||||
? ChangeFeedPolicyState.On
|
||||
: ChangeFeedPolicyState.Off;
|
||||
@@ -811,9 +672,225 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
this.setState({ selectedTab: selectedTab });
|
||||
};
|
||||
|
||||
private saveDatabaseSettings = async (startKey: number): Promise<void> => {
|
||||
if (this.state.isScaleSaveable) {
|
||||
const updateOfferParams: DataModels.UpdateOfferParams = {
|
||||
databaseId: this.database.id(),
|
||||
currentOffer: this.database.offer(),
|
||||
autopilotThroughput: this.state.isAutoPilotSelected ? this.state.autoPilotThroughput : undefined,
|
||||
manualThroughput: this.state.isAutoPilotSelected ? undefined : this.state.throughput,
|
||||
};
|
||||
if (this.hasProvisioningTypeChanged()) {
|
||||
if (this.state.isAutoPilotSelected) {
|
||||
updateOfferParams.migrateToAutoPilot = true;
|
||||
} else {
|
||||
updateOfferParams.migrateToManual = true;
|
||||
}
|
||||
}
|
||||
const updatedOffer: DataModels.Offer = await updateOffer(updateOfferParams);
|
||||
this.database.offer(updatedOffer);
|
||||
this.offer = updatedOffer;
|
||||
this.setState({ isScaleSaveable: false, isScaleDiscardable: false });
|
||||
if (this.state.isAutoPilotSelected) {
|
||||
this.setState({
|
||||
autoPilotThroughput: updatedOffer.autoscaleMaxThroughput,
|
||||
autoPilotThroughputBaseline: updatedOffer.autoscaleMaxThroughput,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
throughput: updatedOffer.manualThroughput,
|
||||
throughputBaseline: updatedOffer.manualThroughput,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this.setBaseline();
|
||||
this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected });
|
||||
traceSuccess(
|
||||
Action.SettingsV2Updated,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
databaseName: this.database.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle(),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
};
|
||||
|
||||
private saveCollectionSettings = async (startKey: number): Promise<void> => {
|
||||
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
|
||||
|
||||
if (this.state.isSubSettingsSaveable || this.state.isIndexingPolicyDirty || this.state.isConflictResolutionDirty) {
|
||||
let defaultTtl: number;
|
||||
switch (this.state.timeToLive) {
|
||||
case TtlType.On:
|
||||
defaultTtl = Number(this.state.timeToLiveSeconds);
|
||||
break;
|
||||
case TtlType.OnNoDefault:
|
||||
defaultTtl = -1;
|
||||
break;
|
||||
case TtlType.Off:
|
||||
default:
|
||||
defaultTtl = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
const wasIndexingPolicyModified = this.state.isIndexingPolicyDirty;
|
||||
newCollection.defaultTtl = defaultTtl;
|
||||
|
||||
newCollection.indexingPolicy = this.state.indexingPolicyContent;
|
||||
|
||||
newCollection.changeFeedPolicy =
|
||||
this.changeFeedPolicyVisible && this.state.changeFeedPolicy === ChangeFeedPolicyState.On
|
||||
? {
|
||||
retentionDuration: Constants.BackendDefaults.maxChangeFeedRetentionDuration,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
newCollection.analyticalStorageTtl = this.getAnalyticalStorageTtl();
|
||||
|
||||
newCollection.geospatialConfig = {
|
||||
type: this.state.geospatialConfigType,
|
||||
};
|
||||
|
||||
const conflictResolutionChanges: DataModels.ConflictResolutionPolicy = this.getUpdatedConflictResolutionPolicy();
|
||||
if (conflictResolutionChanges) {
|
||||
newCollection.conflictResolutionPolicy = conflictResolutionChanges;
|
||||
}
|
||||
|
||||
const updatedCollection: DataModels.Collection = await updateCollection(
|
||||
this.collection.databaseId,
|
||||
this.collection.id(),
|
||||
newCollection
|
||||
);
|
||||
this.collection.rawDataModel = updatedCollection;
|
||||
this.collection.defaultTtl(updatedCollection.defaultTtl);
|
||||
this.collection.analyticalStorageTtl(updatedCollection.analyticalStorageTtl);
|
||||
this.collection.id(updatedCollection.id);
|
||||
this.collection.indexingPolicy(updatedCollection.indexingPolicy);
|
||||
this.collection.conflictResolutionPolicy(updatedCollection.conflictResolutionPolicy);
|
||||
this.collection.changeFeedPolicy(updatedCollection.changeFeedPolicy);
|
||||
this.collection.geospatialConfig(updatedCollection.geospatialConfig);
|
||||
|
||||
if (wasIndexingPolicyModified) {
|
||||
await this.refreshIndexTransformationProgress();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isSubSettingsSaveable: false,
|
||||
isSubSettingsDiscardable: false,
|
||||
isIndexingPolicyDirty: false,
|
||||
isConflictResolutionDirty: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.isMongoIndexingPolicySaveable && this.mongoDBCollectionResource) {
|
||||
try {
|
||||
const newMongoIndexes = this.getMongoIndexesToSave();
|
||||
const newMongoCollection: MongoDBCollectionResource = {
|
||||
...this.mongoDBCollectionResource,
|
||||
indexes: newMongoIndexes,
|
||||
};
|
||||
|
||||
this.mongoDBCollectionResource = await updateMongoDBCollectionThroughRP(
|
||||
this.collection.databaseId,
|
||||
this.collection.id(),
|
||||
newMongoCollection
|
||||
);
|
||||
|
||||
await this.refreshIndexTransformationProgress();
|
||||
this.setState({
|
||||
isMongoIndexingPolicySaveable: false,
|
||||
indexesToDrop: [],
|
||||
indexesToAdd: [],
|
||||
currentMongoIndexes: [...this.mongoDBCollectionResource.indexes],
|
||||
});
|
||||
traceSuccess(
|
||||
Action.MongoIndexUpdated,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
databaseName: this.collection?.databaseId,
|
||||
collectionName: this.collection?.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle(),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
} catch (error) {
|
||||
traceFailure(
|
||||
Action.MongoIndexUpdated,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
databaseName: this.collection?.databaseId,
|
||||
collectionName: this.collection?.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle(),
|
||||
error: getErrorMessage(error),
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.isScaleSaveable) {
|
||||
const updateOfferParams: DataModels.UpdateOfferParams = {
|
||||
databaseId: this.collection.databaseId,
|
||||
collectionId: this.collection.id(),
|
||||
currentOffer: this.collection.offer(),
|
||||
autopilotThroughput: this.state.isAutoPilotSelected ? this.state.autoPilotThroughput : undefined,
|
||||
manualThroughput: this.state.isAutoPilotSelected ? undefined : this.state.throughput,
|
||||
};
|
||||
if (this.hasProvisioningTypeChanged()) {
|
||||
if (this.state.isAutoPilotSelected) {
|
||||
updateOfferParams.migrateToAutoPilot = true;
|
||||
} else {
|
||||
updateOfferParams.migrateToManual = true;
|
||||
}
|
||||
}
|
||||
const updatedOffer: DataModels.Offer = await updateOffer(updateOfferParams);
|
||||
this.collection.offer(updatedOffer);
|
||||
this.offer = updatedOffer;
|
||||
this.setState({ isScaleSaveable: false, isScaleDiscardable: false });
|
||||
if (this.state.isAutoPilotSelected) {
|
||||
this.setState({
|
||||
autoPilotThroughput: updatedOffer.autoscaleMaxThroughput,
|
||||
autoPilotThroughputBaseline: updatedOffer.autoscaleMaxThroughput,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
throughput: updatedOffer.manualThroughput,
|
||||
throughputBaseline: updatedOffer.manualThroughput,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.container.isRefreshingExplorer(false);
|
||||
this.setBaseline();
|
||||
this.setState({ wasAutopilotOriginallySet: this.state.isAutoPilotSelected });
|
||||
traceSuccess(
|
||||
Action.SettingsV2Updated,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
databaseName: this.collection?.databaseId,
|
||||
collectionName: this.collection?.id(),
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.props.settingsTab.tabTitle(),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const scaleComponentProps: ScaleComponentProps = {
|
||||
collection: this.collection,
|
||||
database: this.database,
|
||||
container: this.container,
|
||||
isFixedContainer: this.isFixedContainer,
|
||||
onThroughputChange: this.onThroughputChange,
|
||||
@@ -830,6 +907,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
initialNotification: this.props.settingsTab.pendingNotification(),
|
||||
};
|
||||
|
||||
if (!this.isCollectionSettingsTab) {
|
||||
return (
|
||||
<div className="settingsV2MainContainer">
|
||||
<div className="settingsV2TabsContainer">
|
||||
<ScaleComponent {...scaleComponentProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subSettingsComponentProps: SubSettingsComponentProps = {
|
||||
collection: this.collection,
|
||||
container: this.container,
|
||||
@@ -899,7 +986,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
};
|
||||
|
||||
const tabs: SettingsV2TabInfo[] = [];
|
||||
if (!hasDatabaseSharedThroughput(this.collection) && this.collection.offer()) {
|
||||
if (!hasDatabaseSharedThroughput(this.collection) && this.offer) {
|
||||
tabs.push({
|
||||
tab: SettingsV2TabTypes.ScaleTab,
|
||||
content: <ScaleComponent {...scaleComponentProps} />,
|
||||
|
||||
@@ -375,7 +375,7 @@ export const getThroughputApplyShortDelayMessage = (
|
||||
<Text styles={infoAndToolTipTextStyle} id="throughputApplyShortDelayMessage">
|
||||
A request to increase the throughput is currently in progress. This operation will take some time to complete.
|
||||
<br />
|
||||
Database: {databaseName}, Container: {collectionName}{" "}
|
||||
{collectionName ? `Database: ${databaseName}, Container: ${collectionName} ` : `Database: ${databaseName} `}
|
||||
{getCurrentThroughput(isAutoscale, throughput, throughputUnit)}
|
||||
</Text>
|
||||
);
|
||||
@@ -392,7 +392,7 @@ export const getThroughputApplyLongDelayMessage = (
|
||||
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to
|
||||
complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database: {databaseName}, Container: {collectionName}{" "}
|
||||
{collectionName ? `Database: ${databaseName}, Container: ${collectionName} ` : `Database: ${databaseName} `}
|
||||
{getCurrentThroughput(isAutoscale, throughput, throughputUnit, requestedThroughput)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("ScaleComponent", () => {
|
||||
|
||||
const baseProps: ScaleComponentProps = {
|
||||
collection: collection,
|
||||
database: undefined,
|
||||
container: container,
|
||||
isFixedContainer: false,
|
||||
onThroughputChange: () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { configContext, Platform } from "../../../../ConfigContext";
|
||||
|
||||
export interface ScaleComponentProps {
|
||||
collection: ViewModels.Collection;
|
||||
database: ViewModels.Database;
|
||||
container: Explorer;
|
||||
isFixedContainer: boolean;
|
||||
onThroughputChange: (newThroughput: number) => void;
|
||||
@@ -39,9 +40,16 @@ export interface ScaleComponentProps {
|
||||
|
||||
export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
private isEmulator: boolean;
|
||||
private offer: DataModels.Offer;
|
||||
private databaseId: string;
|
||||
private collectionId: string;
|
||||
|
||||
constructor(props: ScaleComponentProps) {
|
||||
super(props);
|
||||
this.isEmulator = configContext.platform === Platform.Emulator;
|
||||
this.offer = this.props.database?.offer() || this.props.collection?.offer();
|
||||
this.databaseId = this.props.database?.id() || this.props.collection.databaseId;
|
||||
this.collectionId = this.props.collection?.id();
|
||||
}
|
||||
|
||||
public isAutoScaleEnabled = (): boolean => {
|
||||
@@ -87,9 +95,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
return SharedConstants.CollectionCreation.DefaultCollectionRUs400;
|
||||
}
|
||||
|
||||
return (
|
||||
this.props.collection.offer()?.minimumThroughput || SharedConstants.CollectionCreation.DefaultCollectionRUs400
|
||||
);
|
||||
return this.offer?.minimumThroughput || SharedConstants.CollectionCreation.DefaultCollectionRUs400;
|
||||
};
|
||||
|
||||
public getThroughputTitle = (): string => {
|
||||
@@ -115,15 +121,14 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
return this.getLongDelayMessage();
|
||||
}
|
||||
|
||||
const offer = this.props.collection?.offer();
|
||||
if (offer?.offerReplacePending) {
|
||||
const throughput = offer.manualThroughput || offer.autoscaleMaxThroughput;
|
||||
if (this.offer?.offerReplacePending) {
|
||||
const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput;
|
||||
return getThroughputApplyShortDelayMessage(
|
||||
this.props.isAutoPilotSelected,
|
||||
throughput,
|
||||
throughputUnit,
|
||||
this.props.collection.databaseId,
|
||||
this.props.collection.id()
|
||||
this.databaseId,
|
||||
this.collectionId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,7 +140,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
this.canThroughputExceedMaximumValue() &&
|
||||
this.props.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million;
|
||||
|
||||
if (throughputExceedsBackendLimits && !!this.props.collection.partitionKey && !this.props.isFixedContainer) {
|
||||
if (throughputExceedsBackendLimits && !this.props.isFixedContainer) {
|
||||
return updateThroughputBeyondLimitWarningMessage;
|
||||
}
|
||||
|
||||
@@ -154,8 +159,8 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
this.props.wasAutopilotOriginallySet,
|
||||
throughput,
|
||||
throughputUnit,
|
||||
this.props.collection.databaseId,
|
||||
this.props.collection.id(),
|
||||
this.databaseId,
|
||||
this.collectionId,
|
||||
targetThroughput
|
||||
);
|
||||
}
|
||||
@@ -165,15 +170,15 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
private getThroughputInputComponent = (): JSX.Element => (
|
||||
<ThroughputInputAutoPilotV3Component
|
||||
databaseAccount={this.props.container.databaseAccount()}
|
||||
databaseName={this.props.collection.databaseId}
|
||||
collectionName={this.props.collection.id()}
|
||||
databaseName={this.databaseId}
|
||||
collectionName={this.collectionId}
|
||||
serverId={this.props.container.serverId()}
|
||||
throughput={this.props.throughput}
|
||||
throughputBaseline={this.props.throughputBaseline}
|
||||
onThroughputChange={this.props.onThroughputChange}
|
||||
minimum={this.getMinRUs()}
|
||||
maximum={this.getMaxRUs()}
|
||||
isEnabled={!hasDatabaseSharedThroughput(this.props.collection)}
|
||||
isEnabled={!!this.props.database || !hasDatabaseSharedThroughput(this.props.collection)}
|
||||
canExceedMaximumValue={this.canThroughputExceedMaximumValue()}
|
||||
label={this.getThroughputTitle()}
|
||||
isEmulator={this.isEmulator}
|
||||
@@ -189,7 +194,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
onScaleSaveableChange={this.props.onScaleSaveableChange}
|
||||
onScaleDiscardableChange={this.props.onScaleDiscardableChange}
|
||||
getThroughputWarningMessage={this.getThroughputWarningMessage}
|
||||
usageSizeInKB={this.props.collection.usageSizeInKB()}
|
||||
usageSizeInKB={this.props.collection?.usageSizeInKB()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -230,7 +235,7 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
{!this.isAutoScaleEnabled() && (
|
||||
<Stack {...subComponentStackProps}>
|
||||
{this.getThroughputInputComponent()}
|
||||
{this.getStorageCapacityTitle()}
|
||||
{!this.props.database && this.getStorageCapacityTitle()}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { userContext } from "../../../../../UserContext";
|
||||
import { SubscriptionType } from "../../../../../Contracts/SubscriptionType";
|
||||
import { usageInGB, calculateEstimateNumber } from "../../../../../Utils/PricingUtils";
|
||||
import { Features } from "../../../../../Common/Constants";
|
||||
import { minAutoPilotThroughput } from "../../../../../Utils/AutoPilotUtils";
|
||||
|
||||
import * as TelemetryProcessor from "../../../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../../../../Shared/Telemetry/TelemetryConstants";
|
||||
@@ -541,6 +542,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
|
||||
step={AutoPilotUtils.autoPilotIncrementStep}
|
||||
value={this.overrideWithProvisionedThroughputSettings() ? "" : this.props.maxAutoPilotThroughput?.toString()}
|
||||
onChange={this.onAutoPilotThroughputChange}
|
||||
min={minAutoPilotThroughput}
|
||||
/>
|
||||
{!this.overrideWithProvisionedThroughputSettings() && this.getAutoPilotUsageCost()}
|
||||
{this.minRUperGBSurvey()}
|
||||
@@ -579,6 +581,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
|
||||
: this.props.throughput?.toString()
|
||||
}
|
||||
onChange={this.onThroughputChange}
|
||||
min={this.props.minimum}
|
||||
/>
|
||||
{this.state.exceedFreeTierThroughput && (
|
||||
<MessageBar
|
||||
|
||||
@@ -142,6 +142,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
|
||||
id="autopilotInput"
|
||||
key="auto pilot throughput input"
|
||||
label="Max RU/s"
|
||||
min={4000}
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
step={1000}
|
||||
@@ -260,6 +261,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
|
||||
disabled={false}
|
||||
id="throughputInput"
|
||||
key="provisioned throughput input"
|
||||
min={10000}
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
step={100}
|
||||
@@ -533,6 +535,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
|
||||
disabled={false}
|
||||
id="throughputInput"
|
||||
key="provisioned throughput input"
|
||||
min={10000}
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
step={100}
|
||||
|
||||
@@ -23,11 +23,7 @@ exports[`ScaleComponent renders with correct initial notification 1`] = `
|
||||
>
|
||||
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database:
|
||||
test
|
||||
, Container:
|
||||
test
|
||||
|
||||
Database: test, Container: test
|
||||
, Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s
|
||||
</Text>
|
||||
</StyledMessageBarBase>
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("SettingsUtils", () => {
|
||||
readSettings: undefined,
|
||||
onSettingsClick: undefined,
|
||||
loadOffer: undefined,
|
||||
getPendingThroughputSplitNotification: undefined,
|
||||
} as ViewModels.Database;
|
||||
};
|
||||
newCollection.offer(undefined);
|
||||
|
||||
@@ -804,6 +804,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
},
|
||||
"clickHostedAccountSwitch": [Function],
|
||||
"clickHostedDirectorySwitch": [Function],
|
||||
"closeSidePanel": undefined,
|
||||
"collapsedResourceTreeWidth": 36,
|
||||
"collectionCreationDefaults": Object {
|
||||
"storage": "100",
|
||||
@@ -958,7 +959,6 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"isMongoIndexingEnabled": [Function],
|
||||
"isNotebookEnabled": [Function],
|
||||
"isNotebooksEnabledForAccount": [Function],
|
||||
"isNotificationConsoleExpanded": [Function],
|
||||
"isPreferredApiCassandra": [Function],
|
||||
"isPreferredApiDocumentDB": [Function],
|
||||
"isPreferredApiGraph": [Function],
|
||||
@@ -1018,16 +1018,11 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"nonSystemDatabases": [Function],
|
||||
"notebookBasePath": [Function],
|
||||
"notebookServerInfo": [Function],
|
||||
"notificationConsoleComponentAdapter": NotificationConsoleComponentAdapter {
|
||||
"consoleData": [Function],
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
},
|
||||
"notificationConsoleData": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"onSwitchToConnectionString": [Function],
|
||||
"onToggleKeyDown": [Function],
|
||||
"openSidePanel": undefined,
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
@@ -1129,6 +1124,9 @@ exports[`SettingsComponent renders 1`] = `
|
||||
},
|
||||
"selfServeType": [Function],
|
||||
"serverId": [Function],
|
||||
"setInProgressConsoleDataIdToBeDeleted": undefined,
|
||||
"setIsNotificationConsoleExpanded": undefined,
|
||||
"setNotificationConsoleData": undefined,
|
||||
"settingsPane": SettingsPane {
|
||||
"container": [Circular],
|
||||
"crossPartitionQueryEnabled": [Function],
|
||||
@@ -2087,6 +2085,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
},
|
||||
"clickHostedAccountSwitch": [Function],
|
||||
"clickHostedDirectorySwitch": [Function],
|
||||
"closeSidePanel": undefined,
|
||||
"collapsedResourceTreeWidth": 36,
|
||||
"collectionCreationDefaults": Object {
|
||||
"storage": "100",
|
||||
@@ -2241,7 +2240,6 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"isMongoIndexingEnabled": [Function],
|
||||
"isNotebookEnabled": [Function],
|
||||
"isNotebooksEnabledForAccount": [Function],
|
||||
"isNotificationConsoleExpanded": [Function],
|
||||
"isPreferredApiCassandra": [Function],
|
||||
"isPreferredApiDocumentDB": [Function],
|
||||
"isPreferredApiGraph": [Function],
|
||||
@@ -2301,16 +2299,11 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"nonSystemDatabases": [Function],
|
||||
"notebookBasePath": [Function],
|
||||
"notebookServerInfo": [Function],
|
||||
"notificationConsoleComponentAdapter": NotificationConsoleComponentAdapter {
|
||||
"consoleData": [Function],
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
},
|
||||
"notificationConsoleData": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"onSwitchToConnectionString": [Function],
|
||||
"onToggleKeyDown": [Function],
|
||||
"openSidePanel": undefined,
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
@@ -2412,6 +2405,9 @@ exports[`SettingsComponent renders 1`] = `
|
||||
},
|
||||
"selfServeType": [Function],
|
||||
"serverId": [Function],
|
||||
"setInProgressConsoleDataIdToBeDeleted": undefined,
|
||||
"setIsNotificationConsoleExpanded": undefined,
|
||||
"setNotificationConsoleData": undefined,
|
||||
"settingsPane": SettingsPane {
|
||||
"container": [Circular],
|
||||
"crossPartitionQueryEnabled": [Function],
|
||||
@@ -3383,6 +3379,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
},
|
||||
"clickHostedAccountSwitch": [Function],
|
||||
"clickHostedDirectorySwitch": [Function],
|
||||
"closeSidePanel": undefined,
|
||||
"collapsedResourceTreeWidth": 36,
|
||||
"collectionCreationDefaults": Object {
|
||||
"storage": "100",
|
||||
@@ -3537,7 +3534,6 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"isMongoIndexingEnabled": [Function],
|
||||
"isNotebookEnabled": [Function],
|
||||
"isNotebooksEnabledForAccount": [Function],
|
||||
"isNotificationConsoleExpanded": [Function],
|
||||
"isPreferredApiCassandra": [Function],
|
||||
"isPreferredApiDocumentDB": [Function],
|
||||
"isPreferredApiGraph": [Function],
|
||||
@@ -3597,16 +3593,11 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"nonSystemDatabases": [Function],
|
||||
"notebookBasePath": [Function],
|
||||
"notebookServerInfo": [Function],
|
||||
"notificationConsoleComponentAdapter": NotificationConsoleComponentAdapter {
|
||||
"consoleData": [Function],
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
},
|
||||
"notificationConsoleData": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"onSwitchToConnectionString": [Function],
|
||||
"onToggleKeyDown": [Function],
|
||||
"openSidePanel": undefined,
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
@@ -3708,6 +3699,9 @@ exports[`SettingsComponent renders 1`] = `
|
||||
},
|
||||
"selfServeType": [Function],
|
||||
"serverId": [Function],
|
||||
"setInProgressConsoleDataIdToBeDeleted": undefined,
|
||||
"setIsNotificationConsoleExpanded": undefined,
|
||||
"setNotificationConsoleData": undefined,
|
||||
"settingsPane": SettingsPane {
|
||||
"container": [Circular],
|
||||
"crossPartitionQueryEnabled": [Function],
|
||||
@@ -4666,6 +4660,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
},
|
||||
"clickHostedAccountSwitch": [Function],
|
||||
"clickHostedDirectorySwitch": [Function],
|
||||
"closeSidePanel": undefined,
|
||||
"collapsedResourceTreeWidth": 36,
|
||||
"collectionCreationDefaults": Object {
|
||||
"storage": "100",
|
||||
@@ -4820,7 +4815,6 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"isMongoIndexingEnabled": [Function],
|
||||
"isNotebookEnabled": [Function],
|
||||
"isNotebooksEnabledForAccount": [Function],
|
||||
"isNotificationConsoleExpanded": [Function],
|
||||
"isPreferredApiCassandra": [Function],
|
||||
"isPreferredApiDocumentDB": [Function],
|
||||
"isPreferredApiGraph": [Function],
|
||||
@@ -4880,16 +4874,11 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"nonSystemDatabases": [Function],
|
||||
"notebookBasePath": [Function],
|
||||
"notebookServerInfo": [Function],
|
||||
"notificationConsoleComponentAdapter": NotificationConsoleComponentAdapter {
|
||||
"consoleData": [Function],
|
||||
"container": [Circular],
|
||||
"parameters": [Function],
|
||||
},
|
||||
"notificationConsoleData": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"onSwitchToConnectionString": [Function],
|
||||
"onToggleKeyDown": [Function],
|
||||
"openSidePanel": undefined,
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
@@ -4991,6 +4980,9 @@ exports[`SettingsComponent renders 1`] = `
|
||||
},
|
||||
"selfServeType": [Function],
|
||||
"serverId": [Function],
|
||||
"setInProgressConsoleDataIdToBeDeleted": undefined,
|
||||
"setIsNotificationConsoleExpanded": undefined,
|
||||
"setNotificationConsoleData": undefined,
|
||||
"settingsPane": SettingsPane {
|
||||
"container": [Circular],
|
||||
"crossPartitionQueryEnabled": [Function],
|
||||
|
||||
@@ -256,11 +256,7 @@ exports[`SettingsUtils functions render 1`] = `
|
||||
>
|
||||
A request to increase the throughput is currently in progress. This operation will take some time to complete.
|
||||
<br />
|
||||
Database:
|
||||
sampleDb
|
||||
, Container:
|
||||
sampleCollection
|
||||
|
||||
Database: sampleDb, Container: sampleCollection
|
||||
, Current manual throughput: 1000 RU/s
|
||||
</Text>
|
||||
<Text
|
||||
@@ -275,11 +271,7 @@ exports[`SettingsUtils functions render 1`] = `
|
||||
>
|
||||
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database:
|
||||
sampleDb
|
||||
, Container:
|
||||
sampleCollection
|
||||
|
||||
Database: sampleDb, Container: sampleCollection
|
||||
, Current manual throughput: 1000 RU/s, Target manual throughput: 2000
|
||||
</Text>
|
||||
<Text
|
||||
|
||||
@@ -1,63 +1,78 @@
|
||||
import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import { SmartUiComponent, SmartUiDescriptor, UiType } from "./SmartUiComponent";
|
||||
import { SmartUiComponent, SmartUiDescriptor } from "./SmartUiComponent";
|
||||
import { NumberUiType, SmartUiInput } from "../../../SelfServe/SelfServeTypes";
|
||||
|
||||
describe("SmartUiComponent", () => {
|
||||
const exampleData: SmartUiDescriptor = {
|
||||
root: {
|
||||
id: "root",
|
||||
info: {
|
||||
message: "Start at $24/mo per database",
|
||||
messageTKey: "Start at $24/mo per database",
|
||||
link: {
|
||||
href: "https://aka.ms/azure-cosmos-db-pricing",
|
||||
text: "More Details",
|
||||
textTKey: "More Details",
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: "description",
|
||||
input: {
|
||||
dataFieldName: "description",
|
||||
type: "string",
|
||||
description: {
|
||||
textTKey: "this is an example description text.",
|
||||
link: {
|
||||
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
|
||||
textTKey: "Click here for more information.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "throughput",
|
||||
input: {
|
||||
label: "Throughput (input)",
|
||||
labelTKey: "Throughput (input)",
|
||||
dataFieldName: "throughput",
|
||||
type: "number",
|
||||
min: 400,
|
||||
max: 500,
|
||||
step: 10,
|
||||
defaultValue: 400,
|
||||
uiType: UiType.Spinner,
|
||||
uiType: NumberUiType.Spinner,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "throughput2",
|
||||
input: {
|
||||
label: "Throughput (Slider)",
|
||||
labelTKey: "Throughput (Slider)",
|
||||
dataFieldName: "throughput2",
|
||||
type: "number",
|
||||
min: 400,
|
||||
max: 500,
|
||||
step: 10,
|
||||
defaultValue: 400,
|
||||
uiType: UiType.Slider,
|
||||
uiType: NumberUiType.Slider,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "throughput3",
|
||||
input: {
|
||||
label: "Throughput (invalid)",
|
||||
labelTKey: "Throughput (invalid)",
|
||||
dataFieldName: "throughput3",
|
||||
type: "boolean",
|
||||
min: 400,
|
||||
max: 500,
|
||||
step: 10,
|
||||
defaultValue: 400,
|
||||
uiType: UiType.Spinner,
|
||||
uiType: NumberUiType.Spinner,
|
||||
errorMessage: "label, truelabel and falselabel are required for boolean input 'throughput3'",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "containerId",
|
||||
input: {
|
||||
label: "Container id",
|
||||
labelTKey: "Container id",
|
||||
dataFieldName: "containerId",
|
||||
type: "string",
|
||||
},
|
||||
@@ -65,9 +80,9 @@ describe("SmartUiComponent", () => {
|
||||
{
|
||||
id: "analyticalStore",
|
||||
input: {
|
||||
label: "Analytical Store",
|
||||
trueLabel: "Enabled",
|
||||
falseLabel: "Disabled",
|
||||
labelTKey: "Analytical Store",
|
||||
trueLabelTKey: "Enabled",
|
||||
falseLabelTKey: "Disabled",
|
||||
defaultValue: true,
|
||||
dataFieldName: "analyticalStore",
|
||||
type: "boolean",
|
||||
@@ -76,7 +91,7 @@ describe("SmartUiComponent", () => {
|
||||
{
|
||||
id: "database",
|
||||
input: {
|
||||
label: "Database",
|
||||
labelTKey: "Database",
|
||||
dataFieldName: "database",
|
||||
type: "object",
|
||||
choices: [
|
||||
@@ -91,11 +106,64 @@ describe("SmartUiComponent", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("should render", async () => {
|
||||
it("should render and honor input's hidden, disabled state", async () => {
|
||||
const currentValues = new Map<string, SmartUiInput>();
|
||||
const wrapper = shallow(
|
||||
<SmartUiComponent descriptor={exampleData} currentValues={new Map()} onInputChange={undefined} />
|
||||
<SmartUiComponent
|
||||
disabled={false}
|
||||
descriptor={exampleData}
|
||||
currentValues={currentValues}
|
||||
onInputChange={jest.fn()}
|
||||
onError={() => {
|
||||
return;
|
||||
}}
|
||||
getTranslation={(key: string) => {
|
||||
return key;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists("#containerId-textField-input")).toBeTruthy();
|
||||
|
||||
currentValues.set("containerId", { value: "container1", hidden: true });
|
||||
wrapper.setProps({ currentValues });
|
||||
wrapper.update();
|
||||
expect(wrapper.exists("#containerId-textField-input")).toBeFalsy();
|
||||
|
||||
currentValues.set("containerId", { value: "container1", hidden: false, disabled: true });
|
||||
wrapper.setProps({ currentValues });
|
||||
wrapper.update();
|
||||
const containerIdTextField = wrapper.find("#containerId-textField-input");
|
||||
expect(containerIdTextField.props().disabled).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disable all inputs", async () => {
|
||||
const wrapper = shallow(
|
||||
<SmartUiComponent
|
||||
disabled={true}
|
||||
descriptor={exampleData}
|
||||
currentValues={new Map()}
|
||||
onInputChange={jest.fn()}
|
||||
onError={() => {
|
||||
return;
|
||||
}}
|
||||
getTranslation={(key: string) => {
|
||||
return key;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
const throughputSpinner = wrapper.find("#throughput-spinner-input");
|
||||
expect(throughputSpinner.props().disabled).toBeTruthy();
|
||||
const throughput2Slider = wrapper.find("#throughput2-slider-input").childAt(0);
|
||||
expect(throughput2Slider.props().disabled).toBeTruthy();
|
||||
const containerIdTextField = wrapper.find("#containerId-textField-input");
|
||||
expect(containerIdTextField.props().disabled).toBeTruthy();
|
||||
const analyticalStoreToggle = wrapper.find("#analyticalStore-toggle-input");
|
||||
expect(analyticalStoreToggle.props().disabled).toBeTruthy();
|
||||
const databaseDropdown = wrapper.find("#database-dropdown-input");
|
||||
expect(databaseDropdown.props().disabled).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,20 @@ import { SpinButton } from "office-ui-fabric-react/lib/SpinButton";
|
||||
import { Dropdown, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown";
|
||||
import { TextField } from "office-ui-fabric-react/lib/TextField";
|
||||
import { Text } from "office-ui-fabric-react/lib/Text";
|
||||
import { RadioSwitchComponent } from "../RadioSwitchComponent/RadioSwitchComponent";
|
||||
import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack";
|
||||
import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react";
|
||||
import { Link, MessageBar, MessageBarType, Toggle } from "office-ui-fabric-react";
|
||||
import * as InputUtils from "./InputUtils";
|
||||
import "./SmartUiComponent.less";
|
||||
import {
|
||||
ChoiceItem,
|
||||
Description,
|
||||
Info,
|
||||
InputType,
|
||||
InputTypeValue,
|
||||
NumberUiType,
|
||||
SmartUiInput,
|
||||
} from "../../../SelfServe/SelfServeTypes";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Generic UX renderer
|
||||
@@ -19,30 +28,15 @@ import "./SmartUiComponent.less";
|
||||
* - a descriptor of the UX.
|
||||
*/
|
||||
|
||||
export type InputTypeValue = "number" | "string" | "boolean" | "object";
|
||||
|
||||
export enum UiType {
|
||||
Spinner = "Spinner",
|
||||
Slider = "Slider",
|
||||
}
|
||||
|
||||
export type ChoiceItem = { label: string; key: string };
|
||||
|
||||
export type InputType = number | string | boolean | ChoiceItem;
|
||||
|
||||
export interface Info {
|
||||
message: string;
|
||||
link?: {
|
||||
href: string;
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface BaseInput {
|
||||
label: string;
|
||||
interface BaseDisplay {
|
||||
dataFieldName: string;
|
||||
errorMessage?: string;
|
||||
type: InputTypeValue;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface BaseInput extends BaseDisplay {
|
||||
labelTKey: string;
|
||||
placeholderTKey?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
@@ -54,12 +48,12 @@ interface NumberInput extends BaseInput {
|
||||
max: number;
|
||||
step: number;
|
||||
defaultValue?: number;
|
||||
uiType: UiType;
|
||||
uiType: NumberUiType;
|
||||
}
|
||||
|
||||
interface BooleanInput extends BaseInput {
|
||||
trueLabel: string;
|
||||
falseLabel: string;
|
||||
trueLabelTKey: string;
|
||||
falseLabelTKey: string;
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
|
||||
@@ -72,12 +66,16 @@ interface ChoiceInput extends BaseInput {
|
||||
defaultKey?: string;
|
||||
}
|
||||
|
||||
type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
|
||||
interface DescriptionDisplay extends BaseDisplay {
|
||||
description: Description;
|
||||
}
|
||||
|
||||
type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay;
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
info?: Info;
|
||||
input?: AnyInput;
|
||||
input?: AnyDisplay;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
@@ -86,11 +84,13 @@ export interface SmartUiDescriptor {
|
||||
}
|
||||
|
||||
/************************** Component implementation starts here ************************************* */
|
||||
|
||||
export interface SmartUiComponentProps {
|
||||
descriptor: SmartUiDescriptor;
|
||||
currentValues: Map<string, InputType>;
|
||||
onInputChange: (input: AnyInput, newValue: InputType) => void;
|
||||
currentValues: Map<string, SmartUiInput>;
|
||||
onInputChange: (input: AnyDisplay, newValue: InputType) => void;
|
||||
onError: (hasError: boolean) => void;
|
||||
disabled: boolean;
|
||||
getTranslation: TFunction;
|
||||
}
|
||||
|
||||
interface SmartUiComponentState {
|
||||
@@ -98,12 +98,22 @@ interface SmartUiComponentState {
|
||||
}
|
||||
|
||||
export class SmartUiComponent extends React.Component<SmartUiComponentProps, SmartUiComponentState> {
|
||||
private shouldCheckErrors = true;
|
||||
private static readonly labelStyle = {
|
||||
color: "#393939",
|
||||
fontFamily: "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
componentDidUpdate(): void {
|
||||
if (!this.shouldCheckErrors) {
|
||||
this.shouldCheckErrors = true;
|
||||
return;
|
||||
}
|
||||
this.props.onError(this.state.errors.size > 0);
|
||||
this.shouldCheckErrors = false;
|
||||
}
|
||||
|
||||
constructor(props: SmartUiComponentProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -113,11 +123,11 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
|
||||
private renderInfo(info: Info): JSX.Element {
|
||||
return (
|
||||
<MessageBar>
|
||||
{info.message}
|
||||
<MessageBar styles={{ root: { width: 400 } }}>
|
||||
{this.props.getTranslation(info.messageTKey)}
|
||||
{info.link && (
|
||||
<Link href={info.link.href} target="_blank">
|
||||
{info.link.text}
|
||||
{this.props.getTranslation(info.link.textTKey)}
|
||||
</Link>
|
||||
)}
|
||||
</MessageBar>
|
||||
@@ -125,17 +135,20 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
}
|
||||
|
||||
private renderTextInput(input: StringInput): JSX.Element {
|
||||
const value = this.props.currentValues.get(input.dataFieldName) as string;
|
||||
const value = this.props.currentValues.get(input.dataFieldName)?.value as string;
|
||||
const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled;
|
||||
return (
|
||||
<div className="stringInputContainer">
|
||||
<TextField
|
||||
id={`${input.dataFieldName}-textBox-input`}
|
||||
label={input.label}
|
||||
id={`${input.dataFieldName}-textField-input`}
|
||||
label={this.props.getTranslation(input.labelTKey)}
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={input.placeholder}
|
||||
value={value || ""}
|
||||
placeholder={this.props.getTranslation(input.placeholderTKey)}
|
||||
disabled={disabled}
|
||||
onChange={(_, newValue) => this.props.onInputChange(input, newValue)}
|
||||
styles={{
|
||||
root: { width: 400 },
|
||||
subComponentStyles: {
|
||||
label: {
|
||||
root: {
|
||||
@@ -150,13 +163,27 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
);
|
||||
}
|
||||
|
||||
private renderDescription(input: DescriptionDisplay): JSX.Element {
|
||||
const description = input.description;
|
||||
return (
|
||||
<Text id={`${input.dataFieldName}-text-display`}>
|
||||
{this.props.getTranslation(input.description.textTKey)}{" "}
|
||||
{description.link && (
|
||||
<Link target="_blank" href={input.description.link.href}>
|
||||
{this.props.getTranslation(input.description.link.textTKey)}
|
||||
</Link>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
private clearError(dataFieldName: string): void {
|
||||
const { errors } = this.state;
|
||||
errors.delete(dataFieldName);
|
||||
this.setState({ errors });
|
||||
}
|
||||
|
||||
private onValidate = (input: AnyInput, value: string, min: number, max: number): string => {
|
||||
private onValidate = (input: NumberInput, value: string, min: number, max: number): string => {
|
||||
const newValue = InputUtils.onValidateValueChange(value, min, max);
|
||||
const dataFieldName = input.dataFieldName;
|
||||
if (newValue) {
|
||||
@@ -165,13 +192,13 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
return newValue.toString();
|
||||
} else {
|
||||
const { errors } = this.state;
|
||||
errors.set(dataFieldName, `Invalid value ${value}: must be between ${min} and ${max}`);
|
||||
errors.set(dataFieldName, `Invalid value '${value}'. It must be between ${min} and ${max}`);
|
||||
this.setState({ errors });
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
private onIncrement = (input: AnyInput, value: string, step: number, max: number): string => {
|
||||
private onIncrement = (input: NumberInput, value: string, step: number, max: number): string => {
|
||||
const newValue = InputUtils.onIncrementValue(value, step, max);
|
||||
const dataFieldName = input.dataFieldName;
|
||||
if (newValue) {
|
||||
@@ -182,7 +209,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
return undefined;
|
||||
};
|
||||
|
||||
private onDecrement = (input: AnyInput, value: string, step: number, min: number): string => {
|
||||
private onDecrement = (input: NumberInput, value: string, step: number, min: number): string => {
|
||||
const newValue = InputUtils.onDecrementValue(value, step, min);
|
||||
const dataFieldName = input.dataFieldName;
|
||||
if (newValue) {
|
||||
@@ -194,19 +221,20 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
};
|
||||
|
||||
private renderNumberInput(input: NumberInput): JSX.Element {
|
||||
const { label, min, max, dataFieldName, step } = input;
|
||||
const { labelTKey, min, max, dataFieldName, step } = input;
|
||||
const props = {
|
||||
label: label,
|
||||
label: this.props.getTranslation(labelTKey),
|
||||
min: min,
|
||||
max: max,
|
||||
ariaLabel: label,
|
||||
ariaLabel: labelTKey,
|
||||
step: step,
|
||||
};
|
||||
|
||||
const value = this.props.currentValues.get(dataFieldName) as number;
|
||||
if (input.uiType === UiType.Spinner) {
|
||||
const value = this.props.currentValues.get(dataFieldName)?.value as number;
|
||||
const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled;
|
||||
if (input.uiType === NumberUiType.Spinner) {
|
||||
return (
|
||||
<>
|
||||
<Stack styles={{ root: { width: 400 } }} tokens={{ childrenGap: 2 }}>
|
||||
<SpinButton
|
||||
{...props}
|
||||
id={`${input.dataFieldName}-spinner-input`}
|
||||
@@ -215,6 +243,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
onIncrement={(newValue) => this.onIncrement(input, newValue, props.step, props.max)}
|
||||
onDecrement={(newValue) => this.onDecrement(input, newValue, props.step, props.min)}
|
||||
labelPosition={Position.top}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
label: {
|
||||
...SmartUiComponent.labelStyle,
|
||||
@@ -225,16 +254,18 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
{this.state.errors.has(dataFieldName) && (
|
||||
<MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
} else if (input.uiType === UiType.Slider) {
|
||||
} else if (input.uiType === NumberUiType.Slider) {
|
||||
return (
|
||||
<div id={`${input.dataFieldName}-slider-input`}>
|
||||
<Slider
|
||||
{...props}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(newValue) => this.props.onInputChange(input, newValue)}
|
||||
styles={{
|
||||
root: { width: 400 },
|
||||
titleLabel: {
|
||||
...SmartUiComponent.labelStyle,
|
||||
fontWeight: 600,
|
||||
@@ -250,49 +281,44 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
}
|
||||
|
||||
private renderBooleanInput(input: BooleanInput): JSX.Element {
|
||||
const value = this.props.currentValues.get(input.dataFieldName) as boolean;
|
||||
const selectedKey = value || input.defaultValue ? "true" : "false";
|
||||
const value = this.props.currentValues.get(input.dataFieldName)?.value as boolean;
|
||||
const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled;
|
||||
return (
|
||||
<div id={`${input.dataFieldName}-radioSwitch-input`}>
|
||||
<div className="inputLabelContainer">
|
||||
<Text variant="small" nowrap className="inputLabel">
|
||||
{input.label}
|
||||
</Text>
|
||||
</div>
|
||||
<RadioSwitchComponent
|
||||
choices={[
|
||||
{
|
||||
label: input.falseLabel,
|
||||
key: "false",
|
||||
onSelect: () => this.props.onInputChange(input, false),
|
||||
},
|
||||
{
|
||||
label: input.trueLabel,
|
||||
key: "true",
|
||||
onSelect: () => this.props.onInputChange(input, true),
|
||||
},
|
||||
]}
|
||||
selectedKey={selectedKey}
|
||||
/>
|
||||
</div>
|
||||
<Toggle
|
||||
id={`${input.dataFieldName}-toggle-input`}
|
||||
label={this.props.getTranslation(input.labelTKey)}
|
||||
checked={value || false}
|
||||
onText={this.props.getTranslation(input.trueLabelTKey)}
|
||||
offText={this.props.getTranslation(input.falseLabelTKey)}
|
||||
disabled={disabled}
|
||||
onChange={(event, checked: boolean) => this.props.onInputChange(input, checked)}
|
||||
styles={{ root: { width: 400 } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private renderChoiceInput(input: ChoiceInput): JSX.Element {
|
||||
const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input;
|
||||
const value = this.props.currentValues.get(dataFieldName) as string;
|
||||
const { labelTKey, defaultKey, dataFieldName, choices, placeholderTKey } = input;
|
||||
const value = this.props.currentValues.get(dataFieldName)?.value as string;
|
||||
const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled;
|
||||
let selectedKey = value ? value : defaultKey;
|
||||
if (!selectedKey) {
|
||||
selectedKey = "";
|
||||
}
|
||||
return (
|
||||
<Dropdown
|
||||
id={`${input.dataFieldName}-dropown-input`}
|
||||
label={label}
|
||||
selectedKey={value ? value : defaultKey}
|
||||
id={`${input.dataFieldName}-dropdown-input`}
|
||||
label={this.props.getTranslation(labelTKey)}
|
||||
selectedKey={selectedKey}
|
||||
onChange={(_, item: IDropdownOption) => this.props.onInputChange(input, item.key.toString())}
|
||||
placeholder={placeholder}
|
||||
placeholder={this.props.getTranslation(placeholderTKey)}
|
||||
disabled={disabled}
|
||||
options={choices.map((c) => ({
|
||||
key: c.key,
|
||||
text: c.label,
|
||||
text: this.props.getTranslation(c.label),
|
||||
}))}
|
||||
styles={{
|
||||
root: { width: 400 },
|
||||
label: {
|
||||
...SmartUiComponent.labelStyle,
|
||||
fontWeight: 600,
|
||||
@@ -303,16 +329,23 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
);
|
||||
}
|
||||
|
||||
private renderError(input: AnyInput): JSX.Element {
|
||||
private renderError(input: AnyDisplay): JSX.Element {
|
||||
return <MessageBar messageBarType={MessageBarType.error}>Error: {input.errorMessage}</MessageBar>;
|
||||
}
|
||||
|
||||
private renderInput(input: AnyInput): JSX.Element {
|
||||
private renderDisplay(input: AnyDisplay): JSX.Element {
|
||||
if (input.errorMessage) {
|
||||
return this.renderError(input);
|
||||
}
|
||||
const inputHidden = this.props.currentValues.get(input.dataFieldName)?.hidden;
|
||||
if (inputHidden) {
|
||||
return <></>;
|
||||
}
|
||||
switch (input.type) {
|
||||
case "string":
|
||||
if ("description" in input) {
|
||||
return this.renderDescription(input as DescriptionDisplay);
|
||||
}
|
||||
return this.renderTextInput(input as StringInput);
|
||||
case "number":
|
||||
return this.renderNumberInput(input as NumberInput);
|
||||
@@ -326,13 +359,13 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
}
|
||||
|
||||
private renderNode(node: Node): JSX.Element {
|
||||
const containerStackTokens: IStackTokens = { childrenGap: 15 };
|
||||
const containerStackTokens: IStackTokens = { childrenGap: 10 };
|
||||
|
||||
return (
|
||||
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
|
||||
<Stack.Item>
|
||||
{node.info && this.renderInfo(node.info as Info)}
|
||||
{node.input && this.renderInput(node.input)}
|
||||
{node.input && this.renderDisplay(node.input)}
|
||||
</Stack.Item>
|
||||
{node.children && node.children.map((child) => <div key={child.id}>{this.renderNode(child)}</div>)}
|
||||
</Stack>
|
||||
@@ -340,11 +373,6 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
const containerStackTokens: IStackTokens = { childrenGap: 20 };
|
||||
return (
|
||||
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
|
||||
{this.renderNode(this.props.descriptor.root)}
|
||||
</Stack>
|
||||
);
|
||||
return this.renderNode(this.props.descriptor.root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,405 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SmartUiComponent should render 1`] = `
|
||||
exports[`SmartUiComponent disable all inputs 1`] = `
|
||||
<Stack
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"padding": 10,
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 20,
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledMessageBarBase>
|
||||
Start at $24/mo per database
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/azure-cosmos-db-pricing"
|
||||
target="_blank"
|
||||
>
|
||||
More Details
|
||||
</StyledLinkBase>
|
||||
</StyledMessageBarBase>
|
||||
</StackItem>
|
||||
<div
|
||||
key="throughput"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 15,
|
||||
}
|
||||
<StackItem>
|
||||
<StyledMessageBarBase
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Start at $24/mo per database
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/azure-cosmos-db-pricing"
|
||||
target="_blank"
|
||||
>
|
||||
<StackItem>
|
||||
More Details
|
||||
</StyledLinkBase>
|
||||
</StyledMessageBarBase>
|
||||
</StackItem>
|
||||
<div
|
||||
key="description"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<Text
|
||||
id="description-text-display"
|
||||
>
|
||||
this is an example description text.
|
||||
|
||||
<StyledLinkBase
|
||||
href="https://docs.microsoft.com/en-us/azure/cosmos-db/introduction"
|
||||
target="_blank"
|
||||
>
|
||||
Click here for more information.
|
||||
</StyledLinkBase>
|
||||
</Text>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="throughput"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<Stack
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 2,
|
||||
}
|
||||
}
|
||||
>
|
||||
<CustomizedSpinButton
|
||||
ariaLabel="Throughput (input)"
|
||||
decrementButtonIcon={
|
||||
Object {
|
||||
"iconName": "ChevronDownSmall",
|
||||
}
|
||||
}
|
||||
disabled={true}
|
||||
id="throughput-spinner-input"
|
||||
incrementButtonIcon={
|
||||
Object {
|
||||
"iconName": "ChevronUpSmall",
|
||||
}
|
||||
}
|
||||
label="Throughput (input)"
|
||||
labelPosition={0}
|
||||
max={500}
|
||||
min={400}
|
||||
onDecrement={[Function]}
|
||||
onIncrement={[Function]}
|
||||
onValidate={[Function]}
|
||||
step={10}
|
||||
styles={
|
||||
Object {
|
||||
"label": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="throughput2"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<div
|
||||
id="throughput2-slider-input"
|
||||
>
|
||||
<StyledSliderBase
|
||||
ariaLabel="Throughput (Slider)"
|
||||
disabled={true}
|
||||
label="Throughput (Slider)"
|
||||
max={500}
|
||||
min={400}
|
||||
onChange={[Function]}
|
||||
step={10}
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
"titleLabel": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
"valueLabel": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="throughput3"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledMessageBarBase
|
||||
messageBarType={1}
|
||||
>
|
||||
Error:
|
||||
label, truelabel and falselabel are required for boolean input 'throughput3'
|
||||
</StyledMessageBarBase>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="containerId"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<div
|
||||
className="stringInputContainer"
|
||||
>
|
||||
<StyledTextFieldBase
|
||||
disabled={true}
|
||||
id="containerId-textField-input"
|
||||
label="Container id"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
"subComponentStyles": Object {
|
||||
"label": Object {
|
||||
"root": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="analyticalStore"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledToggleBase
|
||||
checked={false}
|
||||
disabled={true}
|
||||
id="analyticalStore-toggle-input"
|
||||
label="Analytical Store"
|
||||
offText="Disabled"
|
||||
onChange={[Function]}
|
||||
onText="Enabled"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="database"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledWithResponsiveMode
|
||||
disabled={true}
|
||||
id="database-dropdown-input"
|
||||
label="Database"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "db1",
|
||||
"text": "Database 1",
|
||||
},
|
||||
Object {
|
||||
"key": "db2",
|
||||
"text": "Database 2",
|
||||
},
|
||||
Object {
|
||||
"key": "db3",
|
||||
"text": "Database 3",
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedKey="db2"
|
||||
styles={
|
||||
Object {
|
||||
"dropdown": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
},
|
||||
"label": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`SmartUiComponent should render and honor input's hidden, disabled state 1`] = `
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledMessageBarBase
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Start at $24/mo per database
|
||||
<StyledLinkBase
|
||||
href="https://aka.ms/azure-cosmos-db-pricing"
|
||||
target="_blank"
|
||||
>
|
||||
More Details
|
||||
</StyledLinkBase>
|
||||
</StyledMessageBarBase>
|
||||
</StackItem>
|
||||
<div
|
||||
key="description"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<Text
|
||||
id="description-text-display"
|
||||
>
|
||||
this is an example description text.
|
||||
|
||||
<StyledLinkBase
|
||||
href="https://docs.microsoft.com/en-us/azure/cosmos-db/introduction"
|
||||
target="_blank"
|
||||
>
|
||||
Click here for more information.
|
||||
</StyledLinkBase>
|
||||
</Text>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="throughput"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<Stack
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 2,
|
||||
}
|
||||
}
|
||||
>
|
||||
<CustomizedSpinButton
|
||||
ariaLabel="Throughput (input)"
|
||||
decrementButtonIcon={
|
||||
@@ -80,210 +433,203 @@ exports[`SmartUiComponent should render 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="throughput2"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 15,
|
||||
}
|
||||
</Stack>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="throughput2"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<div
|
||||
id="throughput2-slider-input"
|
||||
>
|
||||
<StyledSliderBase
|
||||
ariaLabel="Throughput (Slider)"
|
||||
label="Throughput (Slider)"
|
||||
max={500}
|
||||
min={400}
|
||||
onChange={[Function]}
|
||||
step={10}
|
||||
styles={
|
||||
Object {
|
||||
"titleLabel": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
"valueLabel": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="throughput3"
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledMessageBarBase
|
||||
messageBarType={1}
|
||||
>
|
||||
Error:
|
||||
label, truelabel and falselabel are required for boolean input 'throughput3'
|
||||
</StyledMessageBarBase>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="containerId"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<div
|
||||
className="stringInputContainer"
|
||||
>
|
||||
<StyledTextFieldBase
|
||||
id="containerId-textBox-input"
|
||||
label="Container id"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
Object {
|
||||
"subComponentStyles": Object {
|
||||
"label": Object {
|
||||
"root": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="analyticalStore"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<div
|
||||
id="analyticalStore-radioSwitch-input"
|
||||
>
|
||||
<div
|
||||
className="inputLabelContainer"
|
||||
>
|
||||
<Text
|
||||
className="inputLabel"
|
||||
nowrap={true}
|
||||
variant="small"
|
||||
>
|
||||
Analytical Store
|
||||
</Text>
|
||||
</div>
|
||||
<RadioSwitchComponent
|
||||
choices={
|
||||
Array [
|
||||
Object {
|
||||
"key": "false",
|
||||
"label": "Disabled",
|
||||
"onSelect": [Function],
|
||||
},
|
||||
Object {
|
||||
"key": "true",
|
||||
"label": "Enabled",
|
||||
"onSelect": [Function],
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedKey="true"
|
||||
/>
|
||||
</div>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="database"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledWithResponsiveMode
|
||||
id="database-dropown-input"
|
||||
label="Database"
|
||||
<StackItem>
|
||||
<div
|
||||
id="throughput2-slider-input"
|
||||
>
|
||||
<StyledSliderBase
|
||||
ariaLabel="Throughput (Slider)"
|
||||
label="Throughput (Slider)"
|
||||
max={500}
|
||||
min={400}
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "db1",
|
||||
"text": "Database 1",
|
||||
},
|
||||
Object {
|
||||
"key": "db2",
|
||||
"text": "Database 2",
|
||||
},
|
||||
Object {
|
||||
"key": "db3",
|
||||
"text": "Database 3",
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedKey="db2"
|
||||
step={10}
|
||||
styles={
|
||||
Object {
|
||||
"dropdown": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
"label": Object {
|
||||
"titleLabel": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
"valueLabel": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="throughput3"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledMessageBarBase
|
||||
messageBarType={1}
|
||||
>
|
||||
Error:
|
||||
label, truelabel and falselabel are required for boolean input 'throughput3'
|
||||
</StyledMessageBarBase>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="containerId"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<div
|
||||
className="stringInputContainer"
|
||||
>
|
||||
<StyledTextFieldBase
|
||||
id="containerId-textField-input"
|
||||
label="Container id"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
"subComponentStyles": Object {
|
||||
"label": Object {
|
||||
"root": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="analyticalStore"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledToggleBase
|
||||
checked={false}
|
||||
id="analyticalStore-toggle-input"
|
||||
label="Analytical Store"
|
||||
offText="Disabled"
|
||||
onChange={[Function]}
|
||||
onText="Enabled"
|
||||
styles={
|
||||
Object {
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
<div
|
||||
key="database"
|
||||
>
|
||||
<Stack
|
||||
className="widgetRendererContainer"
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StackItem>
|
||||
<StyledWithResponsiveMode
|
||||
id="database-dropdown-input"
|
||||
label="Database"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "db1",
|
||||
"text": "Database 1",
|
||||
},
|
||||
Object {
|
||||
"key": "db2",
|
||||
"text": "Database 2",
|
||||
},
|
||||
Object {
|
||||
"key": "db3",
|
||||
"text": "Database 3",
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedKey="db2"
|
||||
styles={
|
||||
Object {
|
||||
"dropdown": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
},
|
||||
"label": Object {
|
||||
"color": "#393939",
|
||||
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
"root": Object {
|
||||
"width": 400,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import * as ComponentRegisterer from "./ComponentRegisterer";
|
||||
import * as Constants from "../Common/Constants";
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
@@ -47,7 +48,6 @@ import { ExplorerMetrics } from "../Common/Constants";
|
||||
import { ExplorerSettings } from "../Shared/ExplorerSettings";
|
||||
import { FileSystemUtil } from "./Notebook/FileSystemUtil";
|
||||
import { handleOpenAction } from "./OpenActions";
|
||||
import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation";
|
||||
import { IGalleryItem } from "../Juno/JunoClient";
|
||||
import { LoadQueryPane } from "./Panes/LoadQueryPane";
|
||||
import * as Logger from "../Common/Logger";
|
||||
@@ -55,7 +55,6 @@ import { sendMessage, sendCachedDataMessage, handleCachedDataMessage } from "../
|
||||
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
|
||||
import { NotebookUtil } from "./Notebook/NotebookUtil";
|
||||
import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager";
|
||||
import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter";
|
||||
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
|
||||
import { QueriesClient } from "../Common/QueriesClient";
|
||||
import { QuerySelectPane } from "./Panes/Tables/QuerySelectPane";
|
||||
@@ -92,6 +91,8 @@ import { appInsights } from "../Shared/appInsights";
|
||||
import { SelfServeLoadingComponentAdapter } from "../SelfServe/SelfServeLoadingComponentAdapter";
|
||||
import { SelfServeType } from "../SelfServe/SelfServeUtils";
|
||||
import { SelfServeComponentAdapter } from "../SelfServe/SelfServeComponentAdapter";
|
||||
import { GalleryTab } from "./Controls/NotebookGallery/GalleryViewerComponent";
|
||||
import { DeleteCollectionConfirmationPanel } from "./Panes/DeleteCollectionConfirmationPanel";
|
||||
|
||||
BindingHandlersRegisterer.registerBindingHandlers();
|
||||
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
|
||||
@@ -107,6 +108,14 @@ interface AdHocAccessData {
|
||||
readUrl: string;
|
||||
}
|
||||
|
||||
export interface ExplorerParams {
|
||||
setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
|
||||
setNotificationConsoleData: (consoleData: ConsoleData) => void;
|
||||
setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
|
||||
openSidePanel: (headerText: string, panelContent: JSX.Element) => void;
|
||||
closeSidePanel: () => void;
|
||||
}
|
||||
|
||||
export default class Explorer {
|
||||
public flight: ko.Observable<string> = ko.observable<string>(
|
||||
SharedConstants.CollectionCreation.DefaultAddCollectionDefaultFlight
|
||||
@@ -146,11 +155,14 @@ export default class Explorer {
|
||||
public mostRecentActivity: MostRecentActivity.MostRecentActivity;
|
||||
|
||||
// Notification Console
|
||||
public notificationConsoleData: ko.ObservableArray<ConsoleData>;
|
||||
public isNotificationConsoleExpanded: ko.Observable<boolean>;
|
||||
private setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
|
||||
private setNotificationConsoleData: (consoleData: ConsoleData) => void;
|
||||
private setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
|
||||
|
||||
// Panes
|
||||
public contextPanes: ContextualPaneBase[];
|
||||
private openSidePanel: (headerText: string, panelContent: JSX.Element) => void;
|
||||
private closeSidePanel: () => void;
|
||||
|
||||
// Resource Tree
|
||||
public databases: ko.ObservableArray<ViewModels.Database>;
|
||||
@@ -260,7 +272,6 @@ export default class Explorer {
|
||||
// React adapters
|
||||
private commandBarComponentAdapter: CommandBarComponentAdapter;
|
||||
private splashScreenAdapter: SplashScreenComponentAdapter;
|
||||
private notificationConsoleComponentAdapter: NotificationConsoleComponentAdapter;
|
||||
private dialogComponentAdapter: DialogComponentAdapter;
|
||||
private _dialogProps: ko.Observable<DialogProps>;
|
||||
private addSynapseLinkDialog: DialogComponentAdapter;
|
||||
@@ -269,7 +280,13 @@ export default class Explorer {
|
||||
|
||||
private static readonly MaxNbDatabasesToAutoExpand = 5;
|
||||
|
||||
constructor() {
|
||||
constructor(params?: ExplorerParams) {
|
||||
this.setIsNotificationConsoleExpanded = params?.setIsNotificationConsoleExpanded;
|
||||
this.setNotificationConsoleData = params?.setNotificationConsoleData;
|
||||
this.setInProgressConsoleDataIdToBeDeleted = params?.setInProgressConsoleDataIdToBeDeleted;
|
||||
this.openSidePanel = params?.openSidePanel;
|
||||
this.closeSidePanel = params?.closeSidePanel;
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
|
||||
dataExplorerArea: Constants.Areas.ResourceTree,
|
||||
});
|
||||
@@ -414,8 +431,8 @@ export default class Explorer {
|
||||
this.shouldShowShareDialogContents = ko.observable<boolean>(false);
|
||||
this.shouldShowDataAccessExpiryDialog = ko.observable<boolean>(false);
|
||||
this.shouldShowContextSwitchPrompt = ko.observable<boolean>(false);
|
||||
this.isGalleryPublishEnabled = ko.computed<boolean>(() =>
|
||||
this.isFeatureEnabled(Constants.Features.enableGalleryPublish)
|
||||
this.isGalleryPublishEnabled = ko.computed<boolean>(
|
||||
() => configContext.ENABLE_GALLERY_PUBLISH || this.isFeatureEnabled(Constants.Features.enableGalleryPublish)
|
||||
);
|
||||
this.isLinkInjectionEnabled = ko.computed<boolean>(() =>
|
||||
this.isFeatureEnabled(Constants.Features.enableLinkInjection)
|
||||
@@ -430,7 +447,6 @@ export default class Explorer {
|
||||
);
|
||||
|
||||
this.isSchemaEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSchema));
|
||||
this.isNotificationConsoleExpanded = ko.observable<boolean>(false);
|
||||
|
||||
this.isAutoscaleDefaultEnabled = ko.observable<boolean>(false);
|
||||
|
||||
@@ -478,7 +494,6 @@ export default class Explorer {
|
||||
bounds: splitterBounds,
|
||||
direction: SplitterDirection.Vertical,
|
||||
});
|
||||
this.notificationConsoleData = ko.observableArray<ConsoleData>([]);
|
||||
this.defaultExperience = ko.observable<string>();
|
||||
this.databaseAccount.subscribe((databaseAccount) => {
|
||||
const defaultExperience: string = DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount(
|
||||
@@ -892,7 +907,6 @@ export default class Explorer {
|
||||
|
||||
this.commandBarComponentAdapter = new CommandBarComponentAdapter(this);
|
||||
this.selfServeLoadingComponentAdapter = new SelfServeLoadingComponentAdapter();
|
||||
this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this);
|
||||
|
||||
this._initSettings();
|
||||
|
||||
@@ -1349,23 +1363,19 @@ export default class Explorer {
|
||||
}
|
||||
|
||||
public logConsoleData(consoleData: ConsoleData): void {
|
||||
this.notificationConsoleData.splice(0, 0, consoleData);
|
||||
this.setNotificationConsoleData(consoleData);
|
||||
}
|
||||
|
||||
public deleteInProgressConsoleDataWithId(id: string): void {
|
||||
const updatedConsoleData = _.reject(
|
||||
this.notificationConsoleData(),
|
||||
(data: ConsoleData) => data.type === ConsoleDataType.InProgress && data.id === id
|
||||
);
|
||||
this.notificationConsoleData(updatedConsoleData);
|
||||
this.setInProgressConsoleDataIdToBeDeleted(id);
|
||||
}
|
||||
|
||||
public expandConsole(): void {
|
||||
this.isNotificationConsoleExpanded(true);
|
||||
this.setIsNotificationConsoleExpanded(true);
|
||||
}
|
||||
|
||||
public collapseConsole(): void {
|
||||
this.isNotificationConsoleExpanded(false);
|
||||
this.setIsNotificationConsoleExpanded(false);
|
||||
}
|
||||
|
||||
public toggleLeftPaneExpanded() {
|
||||
@@ -1718,58 +1728,7 @@ export default class Explorer {
|
||||
this._addSynapseLinkDialogProps.valueHasMutated();
|
||||
};
|
||||
|
||||
private _shouldProcessMessage(event: MessageEvent): boolean {
|
||||
if (typeof event.data !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (event.data["signature"] !== "pcIframe") {
|
||||
return false;
|
||||
}
|
||||
if (!("data" in event.data)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof event.data["data"] !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// before initialization completed give exception
|
||||
const message = event.data.data;
|
||||
if (!this._importExplorerConfigComplete && message && message.type) {
|
||||
const messageType = message.type;
|
||||
switch (messageType) {
|
||||
case MessageTypes.SendNotification:
|
||||
case MessageTypes.ClearNotification:
|
||||
case MessageTypes.LoadingStatus:
|
||||
case MessageTypes.InitTestExplorer:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!("inputs" in event.data["data"]) && !this._importExplorerConfigComplete) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public handleMessage(event: MessageEvent) {
|
||||
if (isInvalidParentFrameOrigin(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._shouldProcessMessage(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message: any = event.data.data;
|
||||
const inputs: ViewModels.DataExplorerInputsFrame = message.inputs;
|
||||
|
||||
const isRunningInPortal = configContext.platform === Platform.Portal;
|
||||
const isRunningInDevMode = process.env.NODE_ENV === "development";
|
||||
if (inputs && configContext.BACKEND_ENDPOINT && isRunningInPortal && isRunningInDevMode) {
|
||||
inputs.extensionEndpoint = configContext.PROXY_PATH;
|
||||
}
|
||||
|
||||
this.initDataExplorerWithFrameInputs(inputs);
|
||||
|
||||
public handleMessage(message: any) {
|
||||
const openAction: ActionContracts.DataExplorerAction = message.openAction;
|
||||
if (!!openAction) {
|
||||
if (this.isRefreshingExplorer()) {
|
||||
@@ -1874,7 +1833,7 @@ export default class Explorer {
|
||||
}
|
||||
}
|
||||
|
||||
public initDataExplorerWithFrameInputs(inputs: ViewModels.DataExplorerInputsFrame): void {
|
||||
public configure(inputs: ViewModels.DataExplorerInputsFrame): void {
|
||||
if (inputs != null) {
|
||||
// In development mode, save the iframe message from the portal in session storage.
|
||||
// This allows webpack hot reload to funciton properly
|
||||
@@ -1938,6 +1897,9 @@ export default class Explorer {
|
||||
if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) {
|
||||
this.isMongoIndexingEnabled(true);
|
||||
}
|
||||
if (flights.indexOf(Constants.Flights.GalleryPublish) !== -1) {
|
||||
this.isGalleryPublishEnabled = ko.computed<boolean>(() => true);
|
||||
}
|
||||
}
|
||||
|
||||
public findSelectedCollection(): ViewModels.Collection {
|
||||
@@ -2298,7 +2260,7 @@ export default class Explorer {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise<void> {
|
||||
public async publishNotebook(name: string, content: string | unknown, parentDomElement?: HTMLElement): Promise<void> {
|
||||
if (this.notebookManager) {
|
||||
await this.notebookManager.openPublishNotebookPane(
|
||||
name,
|
||||
@@ -2859,10 +2821,36 @@ export default class Explorer {
|
||||
}
|
||||
}
|
||||
|
||||
public async openGallery(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) {
|
||||
public async openGallery(
|
||||
selectedTab?: GalleryTab,
|
||||
notebookUrl?: string,
|
||||
galleryItem?: IGalleryItem,
|
||||
isFavorite?: boolean
|
||||
) {
|
||||
let title: string = "Gallery";
|
||||
let hashLocation: string = "gallery";
|
||||
|
||||
const galleryTabOptions: any = {
|
||||
// GalleryTabOptions
|
||||
account: userContext.databaseAccount,
|
||||
container: this,
|
||||
junoClient: this.notebookManager?.junoClient,
|
||||
selectedTab: selectedTab || GalleryTab.OfficialSamples,
|
||||
notebookUrl,
|
||||
galleryItem,
|
||||
isFavorite,
|
||||
// TabOptions
|
||||
tabKind: ViewModels.CollectionTabKind.Gallery,
|
||||
title: title,
|
||||
tabPath: title,
|
||||
documentClientUtility: null,
|
||||
isActive: ko.observable(false),
|
||||
hashLocation: hashLocation,
|
||||
onUpdateTabsButtons: this.onUpdateTabsButtons,
|
||||
isTabsContentExpanded: ko.observable(true),
|
||||
onLoadStartKey: null,
|
||||
};
|
||||
|
||||
const galleryTabs = this.tabsManager.getTabs(
|
||||
ViewModels.CollectionTabKind.Gallery,
|
||||
(tab) => tab.hashLocation() == hashLocation
|
||||
@@ -2871,31 +2859,12 @@ export default class Explorer {
|
||||
|
||||
if (galleryTab) {
|
||||
this.tabsManager.activateTab(galleryTab);
|
||||
(galleryTab as any).reset(galleryTabOptions);
|
||||
} else {
|
||||
if (!this.galleryTab) {
|
||||
this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab");
|
||||
}
|
||||
|
||||
const newTab = new this.galleryTab.default({
|
||||
// GalleryTabOptions
|
||||
account: userContext.databaseAccount,
|
||||
container: this,
|
||||
junoClient: this.notebookManager?.junoClient,
|
||||
notebookUrl,
|
||||
galleryItem,
|
||||
isFavorite,
|
||||
// TabOptions
|
||||
tabKind: ViewModels.CollectionTabKind.Gallery,
|
||||
title: title,
|
||||
tabPath: title,
|
||||
documentClientUtility: null,
|
||||
isActive: ko.observable(false),
|
||||
hashLocation: hashLocation,
|
||||
onUpdateTabsButtons: this.onUpdateTabsButtons,
|
||||
isTabsContentExpanded: ko.observable(true),
|
||||
onLoadStartKey: null,
|
||||
});
|
||||
|
||||
const newTab = new this.galleryTab.default(galleryTabOptions);
|
||||
this.tabsManager.activateNewTab(newTab);
|
||||
}
|
||||
}
|
||||
@@ -3077,4 +3046,17 @@ export default class Explorer {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public openDeleteCollectionConfirmationPane(): void {
|
||||
this.isFeatureEnabled(Constants.Features.enableKOPanel)
|
||||
? this.deleteCollectionConfirmationPane.open()
|
||||
: this.openSidePanel(
|
||||
"Delete Collection",
|
||||
<DeleteCollectionConfirmationPanel
|
||||
explorer={this}
|
||||
closePanel={() => this.closeSidePanel()}
|
||||
openNotificationConsole={() => this.expandConsole()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,36 @@ export class CommandBarComponentButtonFactory {
|
||||
}
|
||||
|
||||
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);
|
||||
if (addSynapseLink) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import {
|
||||
NotificationConsoleComponentProps,
|
||||
ConsoleData,
|
||||
NotificationConsoleComponent,
|
||||
ConsoleDataType,
|
||||
} from "./NotificationConsoleComponent";
|
||||
@@ -10,38 +9,40 @@ import {
|
||||
describe("NotificationConsoleComponent", () => {
|
||||
const createBlankProps = (): NotificationConsoleComponentProps => {
|
||||
return {
|
||||
consoleData: [],
|
||||
isConsoleExpanded: true,
|
||||
onConsoleDataChange: (consoleData: ConsoleData[]) => {},
|
||||
onConsoleExpandedChange: (isExpanded: boolean) => {},
|
||||
consoleData: undefined,
|
||||
isConsoleExpanded: false,
|
||||
inProgressConsoleDataIdToBeDeleted: "",
|
||||
setIsConsoleExpanded: (isExpanded: boolean): void => {},
|
||||
};
|
||||
};
|
||||
|
||||
it("renders the console (expanded)", () => {
|
||||
it("renders the console", () => {
|
||||
const props = createBlankProps();
|
||||
props.consoleData.push({
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.Info,
|
||||
date: "date",
|
||||
message: "message",
|
||||
});
|
||||
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
};
|
||||
wrapper.setProps(props);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("shows proper progress count", () => {
|
||||
const count = 100;
|
||||
const props = createBlankProps();
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
props.consoleData.push({
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.InProgress,
|
||||
date: "date",
|
||||
date: "date" + i,
|
||||
message: "message",
|
||||
});
|
||||
};
|
||||
wrapper.setProps(props);
|
||||
}
|
||||
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual(count.toString());
|
||||
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual("0");
|
||||
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual("0");
|
||||
@@ -50,16 +51,17 @@ describe("NotificationConsoleComponent", () => {
|
||||
it("shows proper error count", () => {
|
||||
const count = 100;
|
||||
const props = createBlankProps();
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
props.consoleData.push({
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.Error,
|
||||
date: "date",
|
||||
date: "date" + i,
|
||||
message: "message",
|
||||
});
|
||||
};
|
||||
wrapper.setProps(props);
|
||||
}
|
||||
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("0");
|
||||
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual(count.toString());
|
||||
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual("0");
|
||||
@@ -68,31 +70,34 @@ describe("NotificationConsoleComponent", () => {
|
||||
it("shows proper info count", () => {
|
||||
const count = 100;
|
||||
const props = createBlankProps();
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
props.consoleData.push({
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.Info,
|
||||
date: "date",
|
||||
date: "date" + i,
|
||||
message: "message",
|
||||
});
|
||||
};
|
||||
wrapper.setProps(props);
|
||||
}
|
||||
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("0");
|
||||
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual("0");
|
||||
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual(count.toString());
|
||||
});
|
||||
|
||||
const testRenderNotification = (date: string, msg: string, type: ConsoleDataType, iconClassName: string) => {
|
||||
const testRenderNotification = (date: string, message: string, type: ConsoleDataType, iconClassName: string) => {
|
||||
const props = createBlankProps();
|
||||
props.consoleData.push({
|
||||
date: date,
|
||||
message: msg,
|
||||
type: type,
|
||||
});
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
|
||||
props.consoleData = {
|
||||
type,
|
||||
date,
|
||||
message,
|
||||
};
|
||||
wrapper.setProps(props);
|
||||
expect(wrapper.find(".notificationConsoleData .date").text()).toEqual(date);
|
||||
expect(wrapper.find(".notificationConsoleData .message").text()).toEqual(msg);
|
||||
expect(wrapper.find(".notificationConsoleData .message").text()).toEqual(message);
|
||||
expect(wrapper.exists(`.notificationConsoleData .${iconClassName}`));
|
||||
};
|
||||
|
||||
@@ -110,55 +115,78 @@ describe("NotificationConsoleComponent", () => {
|
||||
|
||||
it("clears notifications", () => {
|
||||
const props = createBlankProps();
|
||||
props.consoleData.push({
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.InProgress,
|
||||
date: "date",
|
||||
message: "message1",
|
||||
});
|
||||
props.consoleData.push({
|
||||
};
|
||||
wrapper.setProps(props);
|
||||
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.Error,
|
||||
date: "date",
|
||||
message: "message2",
|
||||
});
|
||||
props.consoleData.push({
|
||||
};
|
||||
wrapper.setProps(props);
|
||||
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.Info,
|
||||
date: "date",
|
||||
message: "message3",
|
||||
});
|
||||
};
|
||||
wrapper.setProps(props);
|
||||
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
wrapper.find(".clearNotificationsButton").simulate("click");
|
||||
|
||||
expect(!wrapper.exists(".notificationConsoleData"));
|
||||
});
|
||||
|
||||
it("collapses and hide content", () => {
|
||||
const props = createBlankProps();
|
||||
props.consoleData.push({
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.Info,
|
||||
date: "date",
|
||||
message: "message",
|
||||
type: ConsoleDataType.Info,
|
||||
});
|
||||
};
|
||||
props.isConsoleExpanded = true;
|
||||
wrapper.setProps(props);
|
||||
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
wrapper.find(".notificationConsoleHeader").simulate("click");
|
||||
expect(!wrapper.exists(".notificationConsoleContent"));
|
||||
});
|
||||
|
||||
it("display latest data in header", () => {
|
||||
const latestData = "latest data";
|
||||
const props1 = createBlankProps();
|
||||
const props2 = createBlankProps();
|
||||
props2.consoleData.push({
|
||||
const props = createBlankProps();
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.Info,
|
||||
date: "date",
|
||||
message: latestData,
|
||||
type: ConsoleDataType.Info,
|
||||
});
|
||||
props2.isConsoleExpanded = true;
|
||||
};
|
||||
props.isConsoleExpanded = true;
|
||||
wrapper.setProps(props);
|
||||
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props1} />);
|
||||
wrapper.setProps(props2);
|
||||
expect(wrapper.find(".headerStatusEllipsis").text()).toEqual(latestData);
|
||||
});
|
||||
|
||||
it("delete in progress message", () => {
|
||||
const props = createBlankProps();
|
||||
props.consoleData = {
|
||||
type: ConsoleDataType.InProgress,
|
||||
date: "date",
|
||||
message: "message",
|
||||
id: "1",
|
||||
};
|
||||
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
|
||||
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("1");
|
||||
|
||||
props.inProgressConsoleDataIdToBeDeleted = "1";
|
||||
wrapper.setProps(props);
|
||||
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,15 +37,15 @@ export interface ConsoleData {
|
||||
|
||||
export interface NotificationConsoleComponentProps {
|
||||
isConsoleExpanded: boolean;
|
||||
onConsoleExpandedChange: (isExpanded: boolean) => void;
|
||||
consoleData: ConsoleData[];
|
||||
onConsoleDataChange: (consoleData: ConsoleData[]) => void;
|
||||
consoleData: ConsoleData;
|
||||
inProgressConsoleDataIdToBeDeleted: string;
|
||||
setIsConsoleExpanded: (isExpanded: boolean) => void;
|
||||
}
|
||||
|
||||
interface NotificationConsoleComponentState {
|
||||
headerStatus: string;
|
||||
selectedFilter: string;
|
||||
isExpanded: boolean;
|
||||
allConsoleData: ConsoleData[];
|
||||
}
|
||||
|
||||
export class NotificationConsoleComponent extends React.Component<
|
||||
@@ -60,28 +60,28 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
{ key: "Error", text: "Error" },
|
||||
];
|
||||
private headerTimeoutId?: number;
|
||||
private prevHeaderStatus: string | null;
|
||||
private prevHeaderStatus: string;
|
||||
private consoleHeaderElement?: HTMLElement;
|
||||
|
||||
constructor(props: NotificationConsoleComponentProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
headerStatus: "",
|
||||
selectedFilter: NotificationConsoleComponent.FilterOptions[0].key || "",
|
||||
isExpanded: props.isConsoleExpanded,
|
||||
headerStatus: undefined,
|
||||
selectedFilter: NotificationConsoleComponent.FilterOptions[0].key,
|
||||
allConsoleData: props.consoleData ? [props.consoleData] : [],
|
||||
};
|
||||
this.prevHeaderStatus = null;
|
||||
this.prevHeaderStatus = undefined;
|
||||
}
|
||||
|
||||
public componentDidUpdate(
|
||||
prevProps: NotificationConsoleComponentProps,
|
||||
prevState: NotificationConsoleComponentState
|
||||
) {
|
||||
const currentHeaderStatus = NotificationConsoleComponent.extractHeaderStatus(this.props);
|
||||
const currentHeaderStatus = NotificationConsoleComponent.extractHeaderStatus(this.props.consoleData);
|
||||
|
||||
if (
|
||||
this.prevHeaderStatus !== currentHeaderStatus &&
|
||||
currentHeaderStatus !== null &&
|
||||
currentHeaderStatus !== undefined &&
|
||||
prevState.headerStatus !== currentHeaderStatus
|
||||
) {
|
||||
this.setHeaderStatus(currentHeaderStatus);
|
||||
@@ -92,10 +92,8 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
// updates: currentHeaderStatus -> "" -> currentHeaderStatus -> "" etc.
|
||||
this.prevHeaderStatus = currentHeaderStatus;
|
||||
|
||||
if (prevProps.isConsoleExpanded !== this.props.isConsoleExpanded) {
|
||||
// Sync state and props
|
||||
// TODO react anti-pattern: remove isExpanded from state which duplicates prop's isConsoleExpanded
|
||||
this.setState({ isExpanded: this.props.isConsoleExpanded });
|
||||
if (this.props.consoleData || this.props.inProgressConsoleDataIdToBeDeleted) {
|
||||
this.updateConsoleData(prevProps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,16 +102,19 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const numInProgress = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.InProgress)
|
||||
const numInProgress = this.state.allConsoleData.filter(
|
||||
(data: ConsoleData) => data.type === ConsoleDataType.InProgress
|
||||
).length;
|
||||
const numErroredItems = this.state.allConsoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Error)
|
||||
.length;
|
||||
const numErroredItems = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Error)
|
||||
.length;
|
||||
const numInfoItems = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Info)
|
||||
const numInfoItems = this.state.allConsoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Info)
|
||||
.length;
|
||||
|
||||
return (
|
||||
<div className="notificationConsoleContainer">
|
||||
<div
|
||||
className="notificationConsoleHeader"
|
||||
id="notificationConsoleHeader"
|
||||
ref={this.setElememntRef}
|
||||
onClick={(event: React.MouseEvent<HTMLDivElement>) => this.expandCollapseConsole()}
|
||||
onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>) => this.onExpandCollapseKeyPress(event)}
|
||||
@@ -143,18 +144,18 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
className="expandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={"console button" + (this.state.isExpanded ? " collapsed" : " expanded")}
|
||||
aria-expanded={!this.state.isExpanded}
|
||||
aria-label={"console button" + (this.props.isConsoleExpanded ? " collapsed" : " expanded")}
|
||||
aria-expanded={!this.props.isConsoleExpanded}
|
||||
>
|
||||
<img
|
||||
src={this.state.isExpanded ? ChevronDownIcon : ChevronUpIcon}
|
||||
alt={this.state.isExpanded ? "ChevronDownIcon" : "ChevronUpIcon"}
|
||||
src={this.props.isConsoleExpanded ? ChevronDownIcon : ChevronUpIcon}
|
||||
alt={this.props.isConsoleExpanded ? "ChevronDownIcon" : "ChevronUpIcon"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimateHeight
|
||||
duration={NotificationConsoleComponent.transitionDurationMs}
|
||||
height={this.state.isExpanded ? "auto" : 0}
|
||||
height={this.props.isConsoleExpanded ? "auto" : 0}
|
||||
onAnimationEnd={this.onConsoleWasExpanded}
|
||||
>
|
||||
<div className="notificationConsoleContents">
|
||||
@@ -189,7 +190,7 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
);
|
||||
}
|
||||
private expandCollapseConsole() {
|
||||
this.setState({ isExpanded: !this.state.isExpanded });
|
||||
this.props.setIsConsoleExpanded(!this.props.isConsoleExpanded);
|
||||
}
|
||||
|
||||
private onExpandCollapseKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
@@ -209,7 +210,7 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
};
|
||||
|
||||
private clearNotifications(): void {
|
||||
this.props.onConsoleDataChange([]);
|
||||
this.setState({ allConsoleData: [] });
|
||||
}
|
||||
|
||||
private renderAllFilteredConsoleData(rowData: ConsoleData[]): JSX.Element[] {
|
||||
@@ -229,12 +230,9 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
};
|
||||
|
||||
private getFilteredConsoleData(): ConsoleData[] {
|
||||
let filterType: ConsoleDataType | null = null;
|
||||
let filterType: ConsoleDataType;
|
||||
|
||||
switch (this.state.selectedFilter) {
|
||||
case "All":
|
||||
filterType = null;
|
||||
break;
|
||||
case "In Progress":
|
||||
filterType = ConsoleDataType.InProgress;
|
||||
break;
|
||||
@@ -245,12 +243,12 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
filterType = ConsoleDataType.Error;
|
||||
break;
|
||||
default:
|
||||
filterType = null;
|
||||
filterType = undefined;
|
||||
}
|
||||
|
||||
return filterType == null
|
||||
? this.props.consoleData
|
||||
: this.props.consoleData.filter((data: ConsoleData) => data.type === filterType);
|
||||
return filterType
|
||||
? this.state.allConsoleData.filter((data: ConsoleData) => data.type === filterType)
|
||||
: this.state.allConsoleData;
|
||||
}
|
||||
|
||||
private setHeaderStatus(statusMessage: string): void {
|
||||
@@ -266,18 +264,43 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
);
|
||||
}
|
||||
|
||||
private static extractHeaderStatus(props: NotificationConsoleComponentProps) {
|
||||
if (props.consoleData && props.consoleData.length > 0) {
|
||||
return props.consoleData[0].message.split(":\n")[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
private static extractHeaderStatus(consoleData: ConsoleData) {
|
||||
return consoleData?.message.split(":\n")[0];
|
||||
}
|
||||
|
||||
private onConsoleWasExpanded = (): void => {
|
||||
this.props.onConsoleExpandedChange(this.state.isExpanded);
|
||||
if (this.state.isExpanded && this.consoleHeaderElement) {
|
||||
if (this.props.isConsoleExpanded && this.consoleHeaderElement) {
|
||||
this.consoleHeaderElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private updateConsoleData = (prevProps: NotificationConsoleComponentProps): void => {
|
||||
if (!this.areConsoleDataEqual(this.props.consoleData, prevProps.consoleData)) {
|
||||
this.setState({ allConsoleData: [this.props.consoleData, ...this.state.allConsoleData] });
|
||||
}
|
||||
|
||||
if (
|
||||
this.props.inProgressConsoleDataIdToBeDeleted &&
|
||||
prevProps.inProgressConsoleDataIdToBeDeleted !== this.props.inProgressConsoleDataIdToBeDeleted
|
||||
) {
|
||||
const allConsoleData = this.state.allConsoleData.filter(
|
||||
(data: ConsoleData) =>
|
||||
!(data.type === ConsoleDataType.InProgress && data.id === this.props.inProgressConsoleDataIdToBeDeleted)
|
||||
);
|
||||
this.setState({ allConsoleData });
|
||||
}
|
||||
};
|
||||
|
||||
private areConsoleDataEqual = (currentData: ConsoleData, prevData: ConsoleData): boolean => {
|
||||
if (!currentData || !prevData) {
|
||||
return !currentData && !prevData;
|
||||
}
|
||||
|
||||
return (
|
||||
currentData.date === prevData.date &&
|
||||
currentData.message === prevData.message &&
|
||||
currentData.type === prevData.type &&
|
||||
currentData.id === prevData.id
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as ko from "knockout";
|
||||
import * as React from "react";
|
||||
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { NotificationConsoleComponent } from "./NotificationConsoleComponent";
|
||||
import { ConsoleData } from "./NotificationConsoleComponent";
|
||||
import Explorer from "../../Explorer";
|
||||
|
||||
export class NotificationConsoleComponentAdapter implements ReactAdapter {
|
||||
public parameters: ko.Observable<number>;
|
||||
public container: Explorer;
|
||||
private consoleData: ko.ObservableArray<ConsoleData>;
|
||||
|
||||
constructor(container: Explorer) {
|
||||
this.container = container;
|
||||
|
||||
this.consoleData = container.notificationConsoleData;
|
||||
this.consoleData.subscribe((newValue: ConsoleData[]) => this.triggerRender());
|
||||
container.isNotificationConsoleExpanded.subscribe(() => this.triggerRender());
|
||||
this.parameters = ko.observable(Date.now());
|
||||
}
|
||||
|
||||
private onConsoleExpandedChange(isExpanded: boolean): void {
|
||||
isExpanded ? this.container.expandConsole() : this.container.collapseConsole();
|
||||
this.triggerRender();
|
||||
}
|
||||
|
||||
private onConsoleDataChange(consoleData: ConsoleData[]): void {
|
||||
this.consoleData(consoleData);
|
||||
this.triggerRender();
|
||||
}
|
||||
|
||||
public renderComponent(): JSX.Element {
|
||||
return (
|
||||
<NotificationConsoleComponent
|
||||
isConsoleExpanded={this.container.isNotificationConsoleExpanded()}
|
||||
onConsoleExpandedChange={this.onConsoleExpandedChange.bind(this)}
|
||||
consoleData={this.consoleData()}
|
||||
onConsoleDataChange={this.onConsoleDataChange.bind(this)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private triggerRender() {
|
||||
window.requestAnimationFrame(() => this.parameters(Date.now()));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,176 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`NotificationConsoleComponent renders the console (expanded) 1`] = `
|
||||
exports[`NotificationConsoleComponent renders the console 1`] = `
|
||||
<div
|
||||
className="notificationConsoleContainer"
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleHeader"
|
||||
id="notificationConsoleHeader"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className="statusBar"
|
||||
>
|
||||
<span
|
||||
className="dataTypeIcons"
|
||||
>
|
||||
<span
|
||||
className="notificationConsoleHeaderIconWithData"
|
||||
>
|
||||
<img
|
||||
alt="in progress items"
|
||||
src=""
|
||||
/>
|
||||
<span
|
||||
className="numInProgress"
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="notificationConsoleHeaderIconWithData"
|
||||
>
|
||||
<img
|
||||
alt="error items"
|
||||
src=""
|
||||
/>
|
||||
<span
|
||||
className="numErroredItems"
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="notificationConsoleHeaderIconWithData"
|
||||
>
|
||||
<img
|
||||
alt="info items"
|
||||
src=""
|
||||
/>
|
||||
<span
|
||||
className="numInfoItems"
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="consoleSplitter"
|
||||
/>
|
||||
<span
|
||||
className="headerStatus"
|
||||
>
|
||||
<span
|
||||
className="headerStatusEllipsis"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded={true}
|
||||
aria-label="console button expanded"
|
||||
className="expandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<img
|
||||
alt="ChevronUpIcon"
|
||||
src=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimateHeight
|
||||
animateOpacity={false}
|
||||
animationStateClasses={
|
||||
Object {
|
||||
"animating": "rah-animating",
|
||||
"animatingDown": "rah-animating--down",
|
||||
"animatingToHeightAuto": "rah-animating--to-height-auto",
|
||||
"animatingToHeightSpecific": "rah-animating--to-height-specific",
|
||||
"animatingToHeightZero": "rah-animating--to-height-zero",
|
||||
"animatingUp": "rah-animating--up",
|
||||
"static": "rah-static",
|
||||
"staticHeightAuto": "rah-static--height-auto",
|
||||
"staticHeightSpecific": "rah-static--height-specific",
|
||||
"staticHeightZero": "rah-static--height-zero",
|
||||
}
|
||||
}
|
||||
applyInlineTransitions={true}
|
||||
delay={0}
|
||||
duration={200}
|
||||
easing="ease"
|
||||
height={0}
|
||||
onAnimationEnd={[Function]}
|
||||
style={Object {}}
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleContents"
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleControls"
|
||||
>
|
||||
<StyledWithResponsiveMode
|
||||
aria-label="All"
|
||||
aria-labelledby="consoleFilterLabel"
|
||||
label="Filter:"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "All",
|
||||
"text": "All",
|
||||
},
|
||||
Object {
|
||||
"key": "In Progress",
|
||||
"text": "In progress",
|
||||
},
|
||||
Object {
|
||||
"key": "Info",
|
||||
"text": "Info",
|
||||
},
|
||||
Object {
|
||||
"key": "Error",
|
||||
"text": "Error",
|
||||
},
|
||||
]
|
||||
}
|
||||
role="combobox"
|
||||
selectedKey="All"
|
||||
/>
|
||||
<span
|
||||
className="consoleSplitter"
|
||||
/>
|
||||
<span
|
||||
className="clearNotificationsButton"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<img
|
||||
alt="clear notifications image"
|
||||
src=""
|
||||
/>
|
||||
Clear Notifications
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="notificationConsoleData"
|
||||
/>
|
||||
</div>
|
||||
</AnimateHeight>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`NotificationConsoleComponent renders the console 2`] = `
|
||||
<div
|
||||
className="notificationConsoleContainer"
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleHeader"
|
||||
id="notificationConsoleHeader"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={0}
|
||||
@@ -64,18 +229,20 @@ exports[`NotificationConsoleComponent renders the console (expanded) 1`] = `
|
||||
>
|
||||
<span
|
||||
className="headerStatusEllipsis"
|
||||
/>
|
||||
>
|
||||
message
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded={false}
|
||||
aria-label="console button collapsed"
|
||||
aria-expanded={true}
|
||||
aria-label="console button expanded"
|
||||
className="expandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<img
|
||||
alt="ChevronDownIcon"
|
||||
alt="ChevronUpIcon"
|
||||
src=""
|
||||
/>
|
||||
</div>
|
||||
@@ -100,7 +267,7 @@ exports[`NotificationConsoleComponent renders the console (expanded) 1`] = `
|
||||
delay={0}
|
||||
duration={200}
|
||||
easing="ease"
|
||||
height="auto"
|
||||
height={0}
|
||||
onAnimationEnd={[Function]}
|
||||
style={Object {}}
|
||||
>
|
||||
|
||||
@@ -45,17 +45,18 @@ type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatc
|
||||
|
||||
const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, children: React.ReactNode) => {
|
||||
const Cell = () => (
|
||||
<DraggableCell id={id} contentRef={contentRef}>
|
||||
<HijackScroll id={id} contentRef={contentRef}>
|
||||
<CellCreator id={id} contentRef={contentRef}>
|
||||
<CellLabeler id={id} contentRef={contentRef}>
|
||||
<HoverableCell id={id} contentRef={contentRef}>
|
||||
{children}
|
||||
</HoverableCell>
|
||||
</CellLabeler>
|
||||
</CellCreator>
|
||||
</HijackScroll>
|
||||
</DraggableCell>
|
||||
// TODO Draggable and HijackScroll not working anymore. Fix or remove when reworking MarkdownCell.
|
||||
// <DraggableCell id={id} contentRef={contentRef}>
|
||||
// <HijackScroll id={id} contentRef={contentRef}>
|
||||
<CellCreator id={id} contentRef={contentRef}>
|
||||
<CellLabeler id={id} contentRef={contentRef}>
|
||||
<HoverableCell id={id} contentRef={contentRef}>
|
||||
{children}
|
||||
</HoverableCell>
|
||||
</CellLabeler>
|
||||
</CellCreator>
|
||||
// </HijackScroll>
|
||||
// </DraggableCell>
|
||||
);
|
||||
|
||||
Cell.defaultProps = { cell_type };
|
||||
|
||||
@@ -818,7 +818,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
|
||||
let indexingPolicy: DataModels.IndexingPolicy;
|
||||
let createMongoWildcardIndex: boolean;
|
||||
// todo - remove mongo indexing policy ticket # 616274
|
||||
if (this.container.isPreferredApiMongoDB()) {
|
||||
if (this.container.isPreferredApiMongoDB() && this.container.isEnableMongoCapabilityPresent()) {
|
||||
createMongoWildcardIndex = this.shouldCreateMongoWildcardIndex();
|
||||
} else if (this.showIndexingOptionsForSharedThroughput()) {
|
||||
if (this.useIndexingForSharedThroughput()) {
|
||||
|
||||
@@ -29,10 +29,6 @@ export abstract class ContextualPaneBase extends WaitsForTemplateViewModel {
|
||||
this.title = ko.observable<string>();
|
||||
this.formErrorsDetails = ko.observable<string>();
|
||||
this.isExecuting = ko.observable<boolean>(false);
|
||||
this.container.isNotificationConsoleExpanded.subscribe((isExpanded: boolean) => {
|
||||
this.resizePane();
|
||||
});
|
||||
this.container.isNotificationConsoleExpanded.extend({ rateLimit: 10 });
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
|
||||
@@ -1,144 +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.isNotificationConsoleExpanded = ko.observable<boolean>(false);
|
||||
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.isNotificationConsoleExpanded = ko.observable<boolean>(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
174
src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx
Normal file
174
src/Explorer/Panes/DeleteCollectionConfirmationPane.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { CassandraAPIDataClient } from "../Tables/TableDataClient";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||
@@ -50,18 +48,7 @@ export default class DeleteCollectionConfirmationPane extends ContextualPaneBase
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
});
|
||||
let promise: Promise<any>;
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
promise = ((<CassandraAPIDataClient>this.container.tableDataClient).deleteTableOrKeyspace(
|
||||
this.container.databaseAccount().properties.cassandraEndpoint,
|
||||
this.container.databaseAccount().id,
|
||||
`DROP TABLE ${selectedCollection.databaseId}.${selectedCollection.id()};`,
|
||||
this.container
|
||||
) as unknown) as Promise<any>;
|
||||
} else {
|
||||
promise = deleteCollection(selectedCollection.databaseId, selectedCollection.id());
|
||||
}
|
||||
return promise.then(
|
||||
return deleteCollection(selectedCollection.databaseId, selectedCollection.id()).then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
|
||||
186
src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx
Normal file
186
src/Explorer/Panes/DeleteCollectionConfirmationPanel.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ describe("Delete Database Confirmation Pane", () => {
|
||||
describe("shouldRecordFeedback()", () => {
|
||||
it("should return true if last non empty database or is last database that has shared throughput, else false", () => {
|
||||
let fakeExplorer = {} as Explorer;
|
||||
fakeExplorer.isNotificationConsoleExpanded = ko.observable<boolean>(false);
|
||||
|
||||
let pane = new DeleteDatabaseConfirmationPane({
|
||||
id: "deletedatabaseconfirmationpane",
|
||||
@@ -92,7 +91,6 @@ describe("Delete Database Confirmation Pane", () => {
|
||||
} as ViewModels.Database;
|
||||
};
|
||||
fakeExplorer.refreshAllDatabases = () => Q.resolve();
|
||||
fakeExplorer.isNotificationConsoleExpanded = ko.observable<boolean>(false);
|
||||
fakeExplorer.selectedDatabaseId = ko.computed<string>(() => selectedDatabaseId);
|
||||
fakeExplorer.isSelectedDatabaseShared = () => false;
|
||||
const SubscriptionId = "testId";
|
||||
|
||||
@@ -52,81 +52,71 @@ export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase {
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
});
|
||||
// TODO: Should not be a Q promise anymore, but the Cassandra code requires it
|
||||
let promise: Q.Promise<any>;
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
promise = (<CassandraAPIDataClient>this.container.tableDataClient).deleteTableOrKeyspace(
|
||||
this.container.databaseAccount().properties.cassandraEndpoint,
|
||||
this.container.databaseAccount().id,
|
||||
`DROP KEYSPACE ${selectedDatabase.id()};`,
|
||||
this.container
|
||||
);
|
||||
} else {
|
||||
promise = Q(deleteDatabase(selectedDatabase.id()));
|
||||
}
|
||||
return promise.then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
this.container.refreshAllDatabases();
|
||||
this.container.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id());
|
||||
this.container.selectedNode(null);
|
||||
selectedDatabase
|
||||
.collections()
|
||||
.forEach((collection: ViewModels.Collection) =>
|
||||
this.container.tabsManager.closeTabsByComparator(
|
||||
(tab) =>
|
||||
tab.node?.id() === collection.id() &&
|
||||
(tab.node as ViewModels.Collection).databaseId === collection.databaseId
|
||||
)
|
||||
);
|
||||
this.resetData();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.DeleteDatabase,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
databaseId: selectedDatabase.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
if (this.shouldRecordFeedback()) {
|
||||
let deleteFeedback = new DeleteFeedback(
|
||||
this.container.databaseAccount().id,
|
||||
this.container.databaseAccount().name,
|
||||
DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience()),
|
||||
this.databaseDeleteFeedback()
|
||||
return Q(
|
||||
deleteDatabase(selectedDatabase.id()).then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
this.container.refreshAllDatabases();
|
||||
this.container.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id());
|
||||
this.container.selectedNode(null);
|
||||
selectedDatabase
|
||||
.collections()
|
||||
.forEach((collection: ViewModels.Collection) =>
|
||||
this.container.tabsManager.closeTabsByComparator(
|
||||
(tab) =>
|
||||
tab.node?.id() === collection.id() &&
|
||||
(tab.node as ViewModels.Collection).databaseId === collection.databaseId
|
||||
)
|
||||
);
|
||||
this.resetData();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.DeleteDatabase,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
databaseId: selectedDatabase.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
TelemetryProcessor.trace(Action.DeleteDatabase, ActionModifiers.Mark, {
|
||||
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)),
|
||||
});
|
||||
if (this.shouldRecordFeedback()) {
|
||||
let deleteFeedback = new DeleteFeedback(
|
||||
this.container.databaseAccount().id,
|
||||
this.container.databaseAccount().name,
|
||||
DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience()),
|
||||
this.databaseDeleteFeedback()
|
||||
);
|
||||
|
||||
this.databaseDeleteFeedback("");
|
||||
TelemetryProcessor.trace(Action.DeleteDatabase, ActionModifiers.Mark, {
|
||||
message: JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback)),
|
||||
});
|
||||
|
||||
this.databaseDeleteFeedback("");
|
||||
}
|
||||
},
|
||||
(error: any) => {
|
||||
this.isExecuting(false);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
this.formErrors(errorMessage);
|
||||
this.formErrorsDetails(errorMessage);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.DeleteDatabase,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
databaseId: selectedDatabase.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
error: errorMessage,
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
},
|
||||
(error: any) => {
|
||||
this.isExecuting(false);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
this.formErrors(errorMessage);
|
||||
this.formErrorsDetails(errorMessage);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.DeleteDatabase,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
databaseId: selectedDatabase.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
error: errorMessage,
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,13 +34,6 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.notificationConsoleSubscription = this.props.container.isNotificationConsoleExpanded.subscribe(() => {
|
||||
this.setState({ panelHeight: this.getPanelHeight() });
|
||||
});
|
||||
this.props.container.isNotificationConsoleExpanded.extend({ rateLimit: 10 });
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.notificationConsoleSubscription && this.notificationConsoleSubscription.dispose();
|
||||
}
|
||||
|
||||
57
src/Explorer/Panes/PanelComponent.less
Normal file
57
src/Explorer/Panes/PanelComponent.less
Normal 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;
|
||||
}
|
||||
41
src/Explorer/Panes/PanelContainerComponent.test.tsx
Normal file
41
src/Explorer/Panes/PanelContainerComponent.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
58
src/Explorer/Panes/PanelContainerComponent.tsx
Normal file
58
src/Explorer/Panes/PanelContainerComponent.tsx
Normal 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";
|
||||
};
|
||||
}
|
||||
29
src/Explorer/Panes/PanelErrorComponent.tsx
Normal file
29
src/Explorer/Panes/PanelErrorComponent.tsx
Normal 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>
|
||||
);
|
||||
15
src/Explorer/Panes/PanelFooterComponent.tsx
Normal file
15
src/Explorer/Panes/PanelFooterComponent.tsx
Normal 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>
|
||||
);
|
||||
@@ -10,7 +10,11 @@ import { ImmutableNotebook } from "@nteract/commutable/src";
|
||||
import { toJS } from "@nteract/commutable";
|
||||
import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
|
||||
import { HttpStatusCodes } from "../../Common/Constants";
|
||||
import { handleError, getErrorMessage } from "../../Common/ErrorHandlingUtils";
|
||||
import { handleError, getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
|
||||
import { GalleryTab } from "../Controls/NotebookGallery/GalleryViewerComponent";
|
||||
import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { FileSystemUtil } from "../Notebook/FileSystemUtil";
|
||||
|
||||
export class PublishNotebookPaneAdapter implements ReactAdapter {
|
||||
parameters: ko.Observable<number>;
|
||||
@@ -66,7 +70,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
||||
onChangeDescription: (newValue: string) => (this.description = newValue),
|
||||
onChangeTags: (newValue: string) => (this.tags = newValue),
|
||||
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
|
||||
onError: this.createFormErrorForLargeImageSelection,
|
||||
onError: this.createFormError,
|
||||
clearFormError: this.clearFormError,
|
||||
};
|
||||
|
||||
@@ -140,10 +144,21 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
||||
this.isExecuting = true;
|
||||
this.triggerRender();
|
||||
|
||||
let startKey: number;
|
||||
|
||||
if (!this.name || !this.description || !this.author || !this.imageSrc) {
|
||||
const formError = `Failed to publish ${this.name} to gallery`;
|
||||
const formErrorDetail = "Name, description, author and cover image are required";
|
||||
this.createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit");
|
||||
this.isExecuting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.name || !this.description || !this.author) {
|
||||
throw new Error("Name, description, and author are required");
|
||||
}
|
||||
startKey = traceStart(Action.NotebooksGalleryPublish, {
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
});
|
||||
|
||||
const response = await this.junoClient.publishNotebook(
|
||||
this.name,
|
||||
@@ -157,17 +172,43 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
||||
|
||||
const data = response.data;
|
||||
if (data) {
|
||||
let isPublishPending = false;
|
||||
|
||||
if (data.pendingScanJobIds?.length > 0) {
|
||||
isPublishPending = true;
|
||||
NotificationConsoleUtils.logConsoleInfo(
|
||||
`Content of ${this.name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).`
|
||||
);
|
||||
} else {
|
||||
NotificationConsoleUtils.logConsoleInfo(`Published ${this.name} to gallery`);
|
||||
this.container.openGallery(GalleryTab.Published);
|
||||
}
|
||||
|
||||
traceSuccess(
|
||||
Action.NotebooksGalleryPublish,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
notebookId: data.id,
|
||||
isPublishPending,
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
traceFailure(
|
||||
Action.NotebooksGalleryPublish,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount()?.name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
error: getErrorMessage(error),
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
this.formError = `Failed to publish ${this.name} to gallery`;
|
||||
this.formError = `Failed to publish ${FileSystemUtil.stripExtension(this.name, "ipynb")} to gallery`;
|
||||
this.formErrorDetail = `${errorMessage}`;
|
||||
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", this.formError);
|
||||
return;
|
||||
@@ -180,7 +221,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
|
||||
this.close();
|
||||
}
|
||||
|
||||
private createFormErrorForLargeImageSelection = (formError: string, formErrorDetail: string, area: string): void => {
|
||||
private createFormError = (formError: string, formErrorDetail: string, area: string): void => {
|
||||
this.formError = formError;
|
||||
this.formErrorDetail = formErrorDetail;
|
||||
handleError(formErrorDetail, area, formError);
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface PublishNotebookPaneProps {
|
||||
notebookAuthor: string;
|
||||
notebookCreatedDate: string;
|
||||
notebookObject: ImmutableNotebook;
|
||||
notebookParentDomElement: HTMLElement;
|
||||
notebookParentDomElement?: HTMLElement;
|
||||
onChangeName: (newValue: string) => void;
|
||||
onChangeDescription: (newValue: string) => void;
|
||||
onChangeTags: (newValue: string) => void;
|
||||
@@ -54,7 +54,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
type: ImageTypes.Url,
|
||||
type: ImageTypes.CustomImage,
|
||||
notebookName: props.notebookName,
|
||||
notebookDescription: "",
|
||||
notebookTags: "",
|
||||
@@ -110,7 +110,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
|
||||
};
|
||||
|
||||
this.descriptionPara1 =
|
||||
"This notebook has your data. Please make sure you delete any sensitive data/output before publishing.";
|
||||
"When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.";
|
||||
|
||||
this.descriptionPara2 = `Would you like to publish and share "${FileSystemUtil.stripExtension(
|
||||
this.props.notebookName,
|
||||
@@ -120,6 +120,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
|
||||
this.thumbnailUrlProps = {
|
||||
label: "Cover image url",
|
||||
ariaLabel: "Cover image url",
|
||||
required: true,
|
||||
onChange: (event, newValue) => {
|
||||
this.props.onChangeImageSrc(newValue);
|
||||
this.setState({ imageSrc: newValue });
|
||||
@@ -140,17 +141,23 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
|
||||
this.props.onError(formError, formErrorDetail, area);
|
||||
};
|
||||
|
||||
const options: ImageTypes[] = [ImageTypes.CustomImage, ImageTypes.Url];
|
||||
|
||||
if (this.props.notebookParentDomElement) {
|
||||
options.push(ImageTypes.TakeScreenshot);
|
||||
if (this.props.notebookObject) {
|
||||
options.push(ImageTypes.UseFirstDisplayOutput);
|
||||
}
|
||||
}
|
||||
|
||||
this.thumbnailSelectorProps = {
|
||||
label: "Cover image",
|
||||
defaultSelectedKey: ImageTypes.Url,
|
||||
defaultSelectedKey: ImageTypes.CustomImage,
|
||||
ariaLabel: "Cover image",
|
||||
options: [
|
||||
ImageTypes.Url,
|
||||
ImageTypes.CustomImage,
|
||||
ImageTypes.TakeScreenshot,
|
||||
ImageTypes.UseFirstDisplayOutput,
|
||||
].map((value: string) => ({ text: value, key: value })),
|
||||
options: options.map((value: string) => ({ text: value, key: value })),
|
||||
onChange: async (event, options) => {
|
||||
this.setState({ imageSrc: undefined });
|
||||
this.props.onChangeImageSrc(undefined);
|
||||
this.props.clearFormError();
|
||||
if (options.text === ImageTypes.TakeScreenshot) {
|
||||
try {
|
||||
@@ -172,11 +179,12 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
|
||||
this.nameProps = {
|
||||
label: "Name",
|
||||
ariaLabel: "Name",
|
||||
defaultValue: this.props.notebookName,
|
||||
defaultValue: FileSystemUtil.stripExtension(this.props.notebookName, "ipynb"),
|
||||
required: true,
|
||||
onChange: (event, newValue) => {
|
||||
this.props.onChangeName(newValue);
|
||||
this.setState({ notebookName: newValue });
|
||||
const notebookName = newValue + ".ipynb";
|
||||
this.props.onChangeName(notebookName);
|
||||
this.setState({ notebookName });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -293,16 +301,16 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
|
||||
thumbnailUrl: this.state.imageSrc,
|
||||
created: this.props.notebookCreatedDate,
|
||||
isSample: false,
|
||||
downloads: 0,
|
||||
favorites: 0,
|
||||
views: 0,
|
||||
downloads: undefined,
|
||||
favorites: undefined,
|
||||
views: undefined,
|
||||
newCellId: undefined,
|
||||
policyViolations: undefined,
|
||||
pendingScanJobIds: undefined,
|
||||
}}
|
||||
isFavorite={false}
|
||||
showDownload={true}
|
||||
showDelete={true}
|
||||
isFavorite={undefined}
|
||||
showDownload={false}
|
||||
showDelete={false}
|
||||
onClick={undefined}
|
||||
onTagClick={undefined}
|
||||
onFavoriteClick={undefined}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
`;
|
||||
@@ -14,7 +14,7 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
|
||||
>
|
||||
<StackItem>
|
||||
<Text>
|
||||
This notebook has your data. Please make sure you delete any sensitive data/output before publishing.
|
||||
When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.
|
||||
</Text>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
@@ -25,7 +25,7 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
|
||||
<StackItem>
|
||||
<StyledTextFieldBase
|
||||
ariaLabel="Name"
|
||||
defaultValue="SampleNotebook.ipynb"
|
||||
defaultValue="SampleNotebook"
|
||||
label="Name"
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
@@ -52,36 +52,29 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
|
||||
<StackItem>
|
||||
<StyledWithResponsiveMode
|
||||
ariaLabel="Cover image"
|
||||
defaultSelectedKey="URL"
|
||||
defaultSelectedKey="Custom Image"
|
||||
label="Cover image"
|
||||
onChange={[Function]}
|
||||
options={
|
||||
Array [
|
||||
Object {
|
||||
"key": "URL",
|
||||
"text": "URL",
|
||||
},
|
||||
Object {
|
||||
"key": "Custom Image",
|
||||
"text": "Custom Image",
|
||||
},
|
||||
Object {
|
||||
"key": "Take Screenshot",
|
||||
"text": "Take Screenshot",
|
||||
},
|
||||
Object {
|
||||
"key": "Use First Display Output",
|
||||
"text": "Use First Display Output",
|
||||
"key": "URL",
|
||||
"text": "URL",
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
<StyledTextFieldBase
|
||||
ariaLabel="Cover image url"
|
||||
label="Cover image url"
|
||||
<input
|
||||
accept="image/*"
|
||||
id="selectImageFile"
|
||||
onChange={[Function]}
|
||||
type="file"
|
||||
/>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
@@ -96,8 +89,8 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
|
||||
"author": "CosmosDB",
|
||||
"created": "2020-07-17T00:00:00Z",
|
||||
"description": "",
|
||||
"downloads": 0,
|
||||
"favorites": 0,
|
||||
"downloads": undefined,
|
||||
"favorites": undefined,
|
||||
"gitSha": undefined,
|
||||
"id": undefined,
|
||||
"isSample": false,
|
||||
@@ -109,12 +102,11 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
|
||||
"",
|
||||
],
|
||||
"thumbnailUrl": undefined,
|
||||
"views": 0,
|
||||
"views": undefined,
|
||||
}
|
||||
}
|
||||
isFavorite={false}
|
||||
showDelete={true}
|
||||
showDownload={true}
|
||||
showDelete={false}
|
||||
showDownload={false}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
|
||||
@@ -240,10 +240,7 @@ function updateTableScrollableRegionHeight(): void {
|
||||
var dataTablesScrollBodyPosY = $(tabElement).find(Constants.htmlSelectors.dataTableScrollBodySelector).offset().top;
|
||||
var dataTablesInfoElem = $(tabElement).find(".dataTables_info");
|
||||
var dataTablesPaginateElem = $(tabElement).find(".dataTables_paginate");
|
||||
const explorer = window.dataExplorer;
|
||||
const notificationConsoleHeight = explorer.isNotificationConsoleExpanded()
|
||||
? 252 /** 32px(header) + 220px(content height) **/
|
||||
: 32; /** Header height **/
|
||||
const notificationConsoleHeight = 32; /** Header height **/
|
||||
|
||||
var scrollHeight =
|
||||
bodyHeight -
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createDocument } from "../../Common/dataAccess/createDocument";
|
||||
import { deleteDocument } from "../../Common/dataAccess/deleteDocument";
|
||||
import { queryDocuments } from "../../Common/dataAccess/queryDocuments";
|
||||
import { updateDocument } from "../../Common/dataAccess/updateDocument";
|
||||
import { userContext } from "../../UserContext";
|
||||
|
||||
export interface CassandraTableKeys {
|
||||
partitionKeys: CassandraTableKey[];
|
||||
@@ -345,7 +346,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
ConsoleDataType.InProgress,
|
||||
`Creating a new keyspace with query ${createKeyspaceQuery}`
|
||||
);
|
||||
this.createOrDeleteQuery(cassandraEndpoint, resourceId, createKeyspaceQuery, explorer)
|
||||
this.createOrDeleteQuery(cassandraEndpoint, resourceId, createKeyspaceQuery)
|
||||
.then(
|
||||
(data: any) => {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
@@ -391,7 +392,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
ConsoleDataType.InProgress,
|
||||
`Creating a new table with query ${createTableQuery}`
|
||||
);
|
||||
this.createOrDeleteQuery(cassandraEndpoint, resourceId, createTableQuery, explorer)
|
||||
this.createOrDeleteQuery(cassandraEndpoint, resourceId, createTableQuery)
|
||||
.then(
|
||||
(data: any) => {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
@@ -416,41 +417,6 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
public deleteTableOrKeyspace(
|
||||
cassandraEndpoint: string,
|
||||
resourceId: string,
|
||||
deleteQuery: string,
|
||||
explorer: Explorer
|
||||
): Q.Promise<any> {
|
||||
const deferred = Q.defer<any>();
|
||||
const notificationId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Deleting resource with query ${deleteQuery}`
|
||||
);
|
||||
this.createOrDeleteQuery(cassandraEndpoint, resourceId, deleteQuery, explorer)
|
||||
.then(
|
||||
() => {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`Successfully deleted resource with query ${deleteQuery}`
|
||||
);
|
||||
deferred.resolve();
|
||||
},
|
||||
(error) => {
|
||||
handleError(
|
||||
error,
|
||||
"DeleteKeyspaceOrTableCassandra",
|
||||
`Error while deleting resource with query ${deleteQuery}`
|
||||
);
|
||||
deferred.reject(error);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(notificationId);
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
public getTableKeys(collection: ViewModels.Collection): Q.Promise<CassandraTableKeys> {
|
||||
if (!!collection.cassandraKeys) {
|
||||
return Q.resolve(collection.cassandraKeys);
|
||||
@@ -551,12 +517,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
private createOrDeleteQuery(
|
||||
cassandraEndpoint: string,
|
||||
resourceId: string,
|
||||
query: string,
|
||||
explorer: Explorer
|
||||
): Q.Promise<any> {
|
||||
private createOrDeleteQuery(cassandraEndpoint: string, resourceId: string, query: string): Q.Promise<any> {
|
||||
const deferred = Q.defer();
|
||||
const authType = window.authType;
|
||||
const apiEndpoint: string =
|
||||
@@ -566,7 +527,7 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
$.ajax(`${configContext.BACKEND_ENDPOINT}/${apiEndpoint}`, {
|
||||
type: "POST",
|
||||
data: {
|
||||
accountName: explorer.databaseAccount() && explorer.databaseAccount().name,
|
||||
accountName: userContext.databaseAccount?.name,
|
||||
cassandraEndpoint: this.trimCassandraEndpoint(cassandraEndpoint),
|
||||
resourceId: resourceId,
|
||||
query: query,
|
||||
|
||||
@@ -387,8 +387,6 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
|
||||
tabTitle: this.tabTitle(),
|
||||
});
|
||||
|
||||
const headerOptions: RequestOptions = { initialHeaders: {} };
|
||||
|
||||
try {
|
||||
const updateOfferParams: DataModels.UpdateOfferParams = {
|
||||
databaseId: this.database.id(),
|
||||
|
||||
@@ -11,6 +11,7 @@ interface GalleryTabOptions extends ViewModels.TabOptions {
|
||||
account: DatabaseAccount;
|
||||
container: Explorer;
|
||||
junoClient: JunoClient;
|
||||
selectedTab: GalleryViewerTab;
|
||||
notebookUrl?: string;
|
||||
galleryItem?: IGalleryItem;
|
||||
isFavorite?: boolean;
|
||||
@@ -21,27 +22,46 @@ interface GalleryTabOptions extends ViewModels.TabOptions {
|
||||
*/
|
||||
export default class GalleryTab extends TabsBase {
|
||||
private container: Explorer;
|
||||
private galleryAndNotebookViewerComponentProps: GalleryAndNotebookViewerComponentProps;
|
||||
public galleryAndNotebookViewerComponentAdapter: GalleryAndNotebookViewerComponentAdapter;
|
||||
|
||||
constructor(options: GalleryTabOptions) {
|
||||
super(options);
|
||||
|
||||
this.container = options.container;
|
||||
const props: GalleryAndNotebookViewerComponentProps = {
|
||||
|
||||
this.galleryAndNotebookViewerComponentProps = {
|
||||
container: options.container,
|
||||
isGalleryPublishEnabled: options.container.isGalleryPublishEnabled(),
|
||||
junoClient: options.junoClient,
|
||||
notebookUrl: options.notebookUrl,
|
||||
galleryItem: options.galleryItem,
|
||||
isFavorite: options.isFavorite,
|
||||
selectedTab: GalleryViewerTab.OfficialSamples,
|
||||
selectedTab: options.selectedTab,
|
||||
sortBy: SortBy.MostViewed,
|
||||
searchText: undefined,
|
||||
};
|
||||
|
||||
this.galleryAndNotebookViewerComponentAdapter = new GalleryAndNotebookViewerComponentAdapter(props);
|
||||
this.galleryAndNotebookViewerComponentAdapter = new GalleryAndNotebookViewerComponentAdapter(
|
||||
this.galleryAndNotebookViewerComponentProps
|
||||
);
|
||||
}
|
||||
|
||||
protected getContainer(): Explorer {
|
||||
public reset(options: GalleryTabOptions) {
|
||||
this.container = options.container;
|
||||
|
||||
this.galleryAndNotebookViewerComponentProps.container = options.container;
|
||||
this.galleryAndNotebookViewerComponentProps.junoClient = options.junoClient;
|
||||
this.galleryAndNotebookViewerComponentProps.notebookUrl = options.notebookUrl;
|
||||
this.galleryAndNotebookViewerComponentProps.galleryItem = options.galleryItem;
|
||||
this.galleryAndNotebookViewerComponentProps.isFavorite = options.isFavorite;
|
||||
this.galleryAndNotebookViewerComponentProps.selectedTab = options.selectedTab;
|
||||
this.galleryAndNotebookViewerComponentProps.sortBy = SortBy.MostViewed;
|
||||
this.galleryAndNotebookViewerComponentProps.searchText = undefined;
|
||||
|
||||
this.galleryAndNotebookViewerComponentAdapter.reset();
|
||||
this.galleryAndNotebookViewerComponentAdapter.triggerRender();
|
||||
}
|
||||
|
||||
public getContainer(): Explorer {
|
||||
return this.container;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-out
|
||||
import InterruptKernelIcon from "../../../images/notebook/Notebook-stop.svg";
|
||||
import KillKernelIcon from "../../../images/notebook/Notebook-stop.svg";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Areas, ArmApiVersions } from "../../Common/Constants";
|
||||
import { CommandBarComponentButtonFactory } from "../Menus/CommandBar/CommandBarComponentButtonFactory";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
@@ -117,7 +117,7 @@ export default class NotebookTabV2 extends TabsBase {
|
||||
return await this.configureServiceEndpoints(this.notebookComponentAdapter.getCurrentKernelName());
|
||||
}
|
||||
|
||||
protected getContainer(): Explorer {
|
||||
public getContainer(): Explorer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
@@ -485,6 +485,10 @@ export default class NotebookTabV2 extends TabsBase {
|
||||
}
|
||||
|
||||
private publishToGallery = async () => {
|
||||
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
|
||||
source: Source.CommandBarMenu,
|
||||
});
|
||||
|
||||
const notebookContent = this.notebookComponentAdapter.getContent();
|
||||
await this.container.publishNotebook(
|
||||
notebookContent.name,
|
||||
|
||||
@@ -58,7 +58,7 @@ export default class NotebookViewerTab extends TabsBase {
|
||||
});
|
||||
}
|
||||
|
||||
protected getContainer(): Explorer {
|
||||
public getContainer(): Explorer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as DataModels from "../../Contracts/DataModels";
|
||||
import TabsBase from "./TabsBase";
|
||||
import { SettingsComponentAdapter } from "../Controls/Settings/SettingsComponentAdapter";
|
||||
import { SettingsComponentProps } from "../Controls/Settings/SettingsComponent";
|
||||
import Explorer from "../Explorer";
|
||||
import { traceFailure } from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
@@ -11,23 +10,27 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { logConsoleError } from "../../Utils/NotificationConsoleUtils";
|
||||
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
|
||||
|
||||
export default class SettingsTabV2 extends TabsBase {
|
||||
export class SettingsTabV2 extends TabsBase {
|
||||
public settingsComponentAdapter: SettingsComponentAdapter;
|
||||
private notificationRead: ko.Observable<boolean>;
|
||||
private notification: DataModels.Notification;
|
||||
private offerRead: ko.Observable<boolean>;
|
||||
private currentCollection: ViewModels.Collection;
|
||||
private options: ViewModels.SettingsTabV2Options;
|
||||
|
||||
constructor(options: ViewModels.SettingsTabV2Options) {
|
||||
constructor(options: ViewModels.TabOptions) {
|
||||
super(options);
|
||||
this.options = options;
|
||||
this.tabId = "SettingsV2-" + this.tabId;
|
||||
const props: SettingsComponentProps = {
|
||||
settingsTab: this,
|
||||
};
|
||||
this.settingsComponentAdapter = new SettingsComponentAdapter(props);
|
||||
this.currentCollection = this.collection as ViewModels.Collection;
|
||||
}
|
||||
}
|
||||
|
||||
export class CollectionSettingsTabV2 extends SettingsTabV2 {
|
||||
private notificationRead: ko.Observable<boolean>;
|
||||
private notification: DataModels.Notification;
|
||||
private offerRead: ko.Observable<boolean>;
|
||||
|
||||
constructor(options: ViewModels.TabOptions) {
|
||||
super(options);
|
||||
|
||||
this.tabId = "SettingsV2-" + this.tabId;
|
||||
this.notificationRead = ko.observable(false);
|
||||
this.offerRead = ko.observable(false);
|
||||
this.settingsComponentAdapter.parameters = ko.computed<boolean>(() => {
|
||||
@@ -45,49 +48,95 @@ export default class SettingsTabV2 extends TabsBase {
|
||||
public async onActivate(): Promise<void> {
|
||||
try {
|
||||
this.isExecuting(true);
|
||||
await this.currentCollection.loadOffer();
|
||||
// passed in options and set by parent as "Settings" by default
|
||||
this.tabTitle(this.currentCollection.offer() ? "Settings" : "Scale & Settings");
|
||||
|
||||
this.options.getPendingNotification.then(
|
||||
(data: DataModels.Notification) => {
|
||||
this.notification = data;
|
||||
this.notificationRead(true);
|
||||
const collection: ViewModels.Collection = this.collection as ViewModels.Collection;
|
||||
await collection.loadOffer();
|
||||
// passed in options and set by parent as "Settings" by default
|
||||
this.tabTitle(collection.offer() ? "Settings" : "Scale & Settings");
|
||||
|
||||
const data: DataModels.Notification = await collection.getPendingThroughputSplitNotification();
|
||||
this.notification = data;
|
||||
this.notificationRead(true);
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
this.notification = undefined;
|
||||
this.notificationRead(true);
|
||||
traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.collection.container.databaseAccount().name,
|
||||
databaseName: this.collection.databaseId,
|
||||
collectionName: this.collection.id(),
|
||||
defaultExperience: this.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle,
|
||||
error: errorMessage,
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
(error) => {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
this.notification = undefined;
|
||||
this.notificationRead(true);
|
||||
traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.options.collection.container.databaseAccount().name,
|
||||
databaseName: this.options.collection.databaseId,
|
||||
collectionName: this.options.collection.id(),
|
||||
defaultExperience: this.options.collection.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle,
|
||||
error: errorMessage,
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
this.options.onLoadStartKey
|
||||
);
|
||||
logConsoleError(
|
||||
`Error while fetching container settings for container ${this.options.collection.id()}: ${errorMessage}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
this.onLoadStartKey
|
||||
);
|
||||
logConsoleError(`Error while fetching container settings for container ${this.collection.id()}: ${errorMessage}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.offerRead(true);
|
||||
this.isExecuting(false);
|
||||
}
|
||||
|
||||
super.onActivate();
|
||||
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.SettingsV2);
|
||||
}
|
||||
|
||||
public getSettingsTabContainer(): Explorer {
|
||||
return this.getContainer();
|
||||
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.CollectionSettingsV2);
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseSettingsTabV2 extends SettingsTabV2 {
|
||||
private notificationRead: ko.Observable<boolean>;
|
||||
private notification: DataModels.Notification;
|
||||
|
||||
constructor(options: ViewModels.TabOptions) {
|
||||
super(options);
|
||||
this.tabId = "DatabaseSettingsV2-" + this.tabId;
|
||||
this.notificationRead = ko.observable(false);
|
||||
this.settingsComponentAdapter.parameters = ko.computed<boolean>(() => {
|
||||
if (this.notificationRead()) {
|
||||
this.pendingNotification(this.notification);
|
||||
this.notification = undefined;
|
||||
this.notificationRead(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public async onActivate(): Promise<void> {
|
||||
try {
|
||||
this.isExecuting(true);
|
||||
|
||||
const data: DataModels.Notification = await this.database.getPendingThroughputSplitNotification();
|
||||
this.notification = data;
|
||||
this.notificationRead(true);
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
this.notification = undefined;
|
||||
this.notificationRead(true);
|
||||
traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseAccountName: this.database?.container.databaseAccount().name,
|
||||
databaseName: this.database.id(),
|
||||
defaultExperience: this.database?.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: this.tabTitle,
|
||||
error: errorMessage,
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
this.onLoadStartKey
|
||||
);
|
||||
logConsoleError(`Error while fetching database settings for database ${this.database.id()}: ${errorMessage}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExecuting(false);
|
||||
}
|
||||
|
||||
super.onActivate();
|
||||
this.database.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettingsV2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default class SparkMasterTab extends TabsBase {
|
||||
this.sparkMasterSrc = ko.observable<string>(sparkMasterEndpoint && sparkMasterEndpoint.endpoint);
|
||||
}
|
||||
|
||||
protected getContainer() {
|
||||
public getContainer() {
|
||||
return this._container;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export default class TabsBase extends WaitsForTemplateViewModel {
|
||||
return Q();
|
||||
}
|
||||
|
||||
protected getContainer(): Explorer {
|
||||
public getContainer(): Explorer {
|
||||
return (this.collection && this.collection.container) || (this.database && this.database.container);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,11 @@
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko if: $data.tabKind === 20 -->
|
||||
<settings-tab-v2 params="{data: $data}"></settings-tab-v2>
|
||||
<collection-settings-tab-v2 params="{data: $data}"></collection-settings-tab-v2>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko if: $data.tabKind === 21 -->
|
||||
<database-settings-tab-v2 params="{data: $data}"></database-settings-tab-v2>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
|
||||
@@ -56,7 +56,7 @@ export default class TerminalTab extends TabsBase {
|
||||
});
|
||||
}
|
||||
|
||||
protected getContainer(): Explorer {
|
||||
public getContainer(): Explorer {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos";
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as _ from "underscore";
|
||||
import UploadWorker from "worker-loader!../../workers/upload";
|
||||
import { AuthType } from "../../AuthType";
|
||||
@@ -27,7 +26,7 @@ import MongoQueryTab from "../Tabs/MongoQueryTab";
|
||||
import MongoShellTab from "../Tabs/MongoShellTab";
|
||||
import QueryTab from "../Tabs/QueryTab";
|
||||
import QueryTablesTab from "../Tabs/QueryTablesTab";
|
||||
import SettingsTabV2 from "../Tabs/SettingsTabV2";
|
||||
import { CollectionSettingsTabV2 } from "../Tabs/SettingsTabV2";
|
||||
import ConflictId from "./ConflictId";
|
||||
import DocumentId from "./DocumentId";
|
||||
import StoredProcedure from "./StoredProcedure";
|
||||
@@ -254,9 +253,9 @@ export default class Collection implements ViewModels.Collection {
|
||||
});
|
||||
}
|
||||
|
||||
public expandCollection(): Q.Promise<any> {
|
||||
public expandCollection(): void {
|
||||
if (this.isCollectionExpanded()) {
|
||||
return Q();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCollectionExpanded(true);
|
||||
@@ -268,8 +267,6 @@ export default class Collection implements ViewModels.Collection {
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ResourceTree,
|
||||
});
|
||||
|
||||
return Q.resolve();
|
||||
}
|
||||
|
||||
public onDocumentDBDocumentsClick() {
|
||||
@@ -547,10 +544,12 @@ export default class Collection implements ViewModels.Collection {
|
||||
});
|
||||
|
||||
const tabTitle = !this.offer() ? "Settings" : "Scale & Settings";
|
||||
const pendingNotificationsPromise: Q.Promise<DataModels.Notification> = this._getPendingThroughputSplitNotification();
|
||||
const matchingTabs = this.container.tabsManager.getTabs(ViewModels.CollectionTabKind.SettingsV2, (tab) => {
|
||||
return tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id();
|
||||
});
|
||||
const matchingTabs = this.container.tabsManager.getTabs(
|
||||
ViewModels.CollectionTabKind.CollectionSettingsV2,
|
||||
(tab) => {
|
||||
return tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id();
|
||||
}
|
||||
);
|
||||
|
||||
const traceStartData = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
@@ -572,26 +571,20 @@ export default class Collection implements ViewModels.Collection {
|
||||
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
|
||||
};
|
||||
|
||||
let settingsTabV2 = matchingTabs && (matchingTabs[0] as SettingsTabV2);
|
||||
this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions, pendingNotificationsPromise);
|
||||
let settingsTabV2 = matchingTabs && (matchingTabs[0] as CollectionSettingsTabV2);
|
||||
this.launchSettingsTabV2(settingsTabV2, traceStartData, settingsTabOptions);
|
||||
};
|
||||
|
||||
private launchSettingsTabV2 = (
|
||||
settingsTabV2: SettingsTabV2,
|
||||
settingsTabV2: CollectionSettingsTabV2,
|
||||
traceStartData: any,
|
||||
settingsTabOptions: ViewModels.TabOptions,
|
||||
getPendingNotification: Q.Promise<DataModels.Notification>
|
||||
settingsTabOptions: ViewModels.TabOptions
|
||||
): void => {
|
||||
const settingsTabV2Options: ViewModels.SettingsTabV2Options = {
|
||||
...settingsTabOptions,
|
||||
getPendingNotification: getPendingNotification,
|
||||
};
|
||||
|
||||
if (!settingsTabV2) {
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, traceStartData);
|
||||
settingsTabV2Options.onLoadStartKey = startKey;
|
||||
settingsTabV2Options.tabKind = ViewModels.CollectionTabKind.SettingsV2;
|
||||
settingsTabV2 = new SettingsTabV2(settingsTabV2Options);
|
||||
settingsTabOptions.onLoadStartKey = startKey;
|
||||
settingsTabOptions.tabKind = ViewModels.CollectionTabKind.CollectionSettingsV2;
|
||||
settingsTabV2 = new CollectionSettingsTabV2(settingsTabOptions);
|
||||
this.container.tabsManager.activateNewTab(settingsTabV2);
|
||||
} else {
|
||||
this.container.tabsManager.activateTab(settingsTabV2);
|
||||
@@ -976,23 +969,19 @@ export default class Collection implements ViewModels.Collection {
|
||||
this.uploadFiles(event.originalEvent.dataTransfer.files);
|
||||
}
|
||||
|
||||
public onDeleteCollectionContextMenuClick(source: ViewModels.Collection, event: MouseEvent | KeyboardEvent) {
|
||||
this.container.deleteCollectionConfirmationPane.open();
|
||||
}
|
||||
|
||||
public uploadFiles = (fileList: FileList): Q.Promise<UploadDetails> => {
|
||||
public uploadFiles = (fileList: FileList): Promise<UploadDetails> => {
|
||||
// TODO: right now web worker is not working with AAD flow. Use main thread for upload for now until we have backend upload capability
|
||||
if (configContext.platform === Platform.Hosted && window.authType === AuthType.AAD) {
|
||||
return this._uploadFilesCors(fileList);
|
||||
}
|
||||
const documentUploader: Worker = new UploadWorker();
|
||||
const deferred: Q.Deferred<UploadDetails> = Q.defer<UploadDetails>();
|
||||
let inProgressNotificationId: string = "";
|
||||
|
||||
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 numFailed: number = event.data.numUploadsFailed;
|
||||
const runtimeError: string = event.data.runtimeError;
|
||||
@@ -1001,31 +990,26 @@ export default class Collection implements ViewModels.Collection {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(inProgressNotificationId);
|
||||
documentUploader.terminate();
|
||||
if (!!runtimeError) {
|
||||
deferred.reject(runtimeError);
|
||||
reject(runtimeError);
|
||||
} else if (numSuccessful === 0) {
|
||||
// all uploads failed
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to upload all documents to container ${this.id()}`
|
||||
);
|
||||
NotificationConsoleUtils.logConsoleError(`Failed to upload all documents to container ${this.id()}`);
|
||||
} else if (numFailed > 0) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
NotificationConsoleUtils.logConsoleError(
|
||||
`Failed to upload ${numFailed} of ${numSuccessful + numFailed} documents to container ${this.id()}`
|
||||
);
|
||||
} else {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
NotificationConsoleUtils.logConsoleInfo(
|
||||
`Successfully uploaded all ${numSuccessful} documents to container ${this.id()}`
|
||||
);
|
||||
}
|
||||
this._logUploadDetailsInConsole(uploadDetails);
|
||||
deferred.resolve(uploadDetails);
|
||||
resolve(uploadDetails);
|
||||
};
|
||||
documentUploader.onerror = (event: ErrorEvent): void => {
|
||||
function onerror(reject: (reason: any) => void, event: ErrorEvent) {
|
||||
documentUploader.terminate();
|
||||
deferred.reject(event.error);
|
||||
};
|
||||
reject(event.error);
|
||||
}
|
||||
|
||||
const uploaderMessage: StartUploadMessageParams = {
|
||||
files: fileList,
|
||||
@@ -1040,42 +1024,68 @@ export default class Collection implements ViewModels.Collection {
|
||||
},
|
||||
};
|
||||
|
||||
documentUploader.postMessage(uploaderMessage);
|
||||
inProgressNotificationId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Uploading and creating documents in container ${this.id()}`
|
||||
);
|
||||
return new Promise<UploadDetails>((resolve, reject) => {
|
||||
documentUploader.onmessage = onmessage.bind(null, resolve, reject);
|
||||
documentUploader.onerror = onerror.bind(null, reject);
|
||||
|
||||
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> {
|
||||
const deferred: Q.Deferred<UploadDetails> = Q.defer<UploadDetails>();
|
||||
const promises: Array<Q.Promise<UploadDetailsRecord>> = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
promises.push(this._uploadFile(files[i]));
|
||||
public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
|
||||
if (!this.container) {
|
||||
return undefined;
|
||||
}
|
||||
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> {
|
||||
const deferred: Q.Deferred<UploadDetailsRecord> = Q.defer();
|
||||
private async _uploadFilesCors(files: FileList): Promise<UploadDetails> {
|
||||
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();
|
||||
reader.onload = (evt: any): void => {
|
||||
const onload = (resolve: (value: UploadDetailsRecord) => void, evt: any): void => {
|
||||
const fileData: string = evt.target.result;
|
||||
this._createDocumentsFromFile(file.name, fileData).then((record) => {
|
||||
deferred.resolve(record);
|
||||
});
|
||||
this._createDocumentsFromFile(file.name, fileData).then((record) => resolve(record));
|
||||
};
|
||||
|
||||
reader.onerror = (evt: ProgressEvent): void => {
|
||||
deferred.resolve({
|
||||
const onerror = (resolve: (value: UploadDetailsRecord) => void, evt: ProgressEvent): void => {
|
||||
resolve({
|
||||
fileName: file.name,
|
||||
numSucceeded: 0,
|
||||
numFailed: 1,
|
||||
@@ -1083,9 +1093,11 @@ export default class Collection implements ViewModels.Collection {
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
|
||||
return deferred.promise;
|
||||
return new Promise<UploadDetailsRecord>((resolve) => {
|
||||
reader.onload = onload.bind(this, resolve);
|
||||
reader.onerror = onerror.bind(this, resolve);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
const uploadDetailsRecords: UploadDetailsRecord[] = uploadDetails.data;
|
||||
const numFiles: number = uploadDetailsRecords.length;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as _ from "underscore";
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import DatabaseSettingsTab from "../Tabs/DatabaseSettingsTab";
|
||||
import { DatabaseSettingsTabV2 } from "../Tabs/SettingsTabV2";
|
||||
import Collection from "./Collection";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
|
||||
@@ -16,7 +16,6 @@ import { readCollections } from "../../Common/dataAccess/readCollections";
|
||||
import { JunoClient, IJunoResponse } from "../../Juno/JunoClient";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer";
|
||||
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
|
||||
import { fetchPortalNotifications } from "../../Common/PortalNotifications";
|
||||
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
|
||||
|
||||
@@ -59,12 +58,17 @@ export default class Database implements ViewModels.Database {
|
||||
dataExplorerArea: Constants.Areas.ResourceTree,
|
||||
});
|
||||
|
||||
const pendingNotificationsPromise: Q.Promise<DataModels.Notification> = this._getPendingThroughputSplitNotification();
|
||||
const matchingTabs = this.container.tabsManager.getTabs(
|
||||
ViewModels.CollectionTabKind.DatabaseSettings,
|
||||
(tab) => tab.node?.id() === this.id()
|
||||
const pendingNotificationsPromise: Promise<DataModels.Notification> = this.getPendingThroughputSplitNotification();
|
||||
const useDatabaseSettingsTabV1: boolean = this.container.isFeatureEnabled(
|
||||
Constants.Features.enableDatabaseSettingsTabV1
|
||||
);
|
||||
let settingsTab: DatabaseSettingsTab = matchingTabs && (matchingTabs[0] as DatabaseSettingsTab);
|
||||
const tabKind: ViewModels.CollectionTabKind = useDatabaseSettingsTabV1
|
||||
? ViewModels.CollectionTabKind.DatabaseSettings
|
||||
: ViewModels.CollectionTabKind.DatabaseSettingsV2;
|
||||
const matchingTabs = this.container.tabsManager.getTabs(tabKind, (tab) => tab.node?.id() === this.id());
|
||||
let settingsTab: DatabaseSettingsTab | DatabaseSettingsTabV2 = useDatabaseSettingsTabV1
|
||||
? (matchingTabs?.[0] as DatabaseSettingsTab)
|
||||
: (matchingTabs?.[0] as DatabaseSettingsTabV2);
|
||||
if (!settingsTab) {
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.Tab, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
@@ -75,9 +79,11 @@ export default class Database implements ViewModels.Database {
|
||||
});
|
||||
pendingNotificationsPromise.then(
|
||||
(data: any) => {
|
||||
const pendingNotification: DataModels.Notification = data && data[0];
|
||||
settingsTab = new DatabaseSettingsTab({
|
||||
tabKind: ViewModels.CollectionTabKind.DatabaseSettings,
|
||||
const pendingNotification: DataModels.Notification = data?.[0];
|
||||
const tabOptions: ViewModels.TabOptions = {
|
||||
tabKind: useDatabaseSettingsTabV1
|
||||
? ViewModels.CollectionTabKind.DatabaseSettings
|
||||
: ViewModels.CollectionTabKind.DatabaseSettingsV2,
|
||||
title: "Scale",
|
||||
tabPath: "",
|
||||
node: this,
|
||||
@@ -87,8 +93,10 @@ export default class Database implements ViewModels.Database {
|
||||
isActive: ko.observable(false),
|
||||
onLoadStartKey: startKey,
|
||||
onUpdateTabsButtons: this.container.onUpdateTabsButtons,
|
||||
});
|
||||
|
||||
};
|
||||
settingsTab = useDatabaseSettingsTabV1
|
||||
? new DatabaseSettingsTab(tabOptions)
|
||||
: new DatabaseSettingsTabV2(tabOptions);
|
||||
settingsTab.pendingNotification(pendingNotification);
|
||||
this.container.tabsManager.activateNewTab(settingsTab);
|
||||
},
|
||||
@@ -221,47 +229,40 @@ export default class Database implements ViewModels.Database {
|
||||
}
|
||||
}
|
||||
|
||||
private _getPendingThroughputSplitNotification(): Q.Promise<DataModels.Notification> {
|
||||
public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
|
||||
if (!this.container) {
|
||||
return Q.resolve(undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deferred: Q.Deferred<DataModels.Notification> = Q.defer<DataModels.Notification>();
|
||||
fetchPortalNotifications().then(
|
||||
(notifications) => {
|
||||
if (!notifications || notifications.length === 0) {
|
||||
deferred.resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingNotification = _.find(notifications, (notification: DataModels.Notification) => {
|
||||
const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress");
|
||||
return (
|
||||
notification.kind === "message" &&
|
||||
!notification.collectionName &&
|
||||
notification.databaseName === this.id() &&
|
||||
notification.description &&
|
||||
throughputUpdateRegExp.test(notification.description)
|
||||
);
|
||||
});
|
||||
|
||||
deferred.resolve(pendingNotification);
|
||||
},
|
||||
(error: any) => {
|
||||
Logger.logError(
|
||||
JSON.stringify({
|
||||
error: getErrorMessage(error),
|
||||
accountName: this.container && this.container.databaseAccount(),
|
||||
databaseName: this.id(),
|
||||
collectionName: this.id(),
|
||||
}),
|
||||
"Settings tree node"
|
||||
);
|
||||
deferred.resolve(undefined);
|
||||
try {
|
||||
const notifications: DataModels.Notification[] = await fetchPortalNotifications();
|
||||
if (!notifications || notifications.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
|
||||
return deferred.promise;
|
||||
return _.find(notifications, (notification: DataModels.Notification) => {
|
||||
const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress");
|
||||
return (
|
||||
notification.kind === "message" &&
|
||||
!notification.collectionName &&
|
||||
notification.databaseName === this.id() &&
|
||||
notification.description &&
|
||||
throughputUpdateRegExp.test(notification.description)
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.logError(
|
||||
JSON.stringify({
|
||||
error: getErrorMessage(error),
|
||||
accountName: this.container && this.container.databaseAccount(),
|
||||
databaseName: this.id(),
|
||||
collectionName: this.id(),
|
||||
}),
|
||||
"Settings tree node"
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getDeltaCollections(
|
||||
|
||||
@@ -41,9 +41,9 @@ export default class ResourceTokenCollection implements ViewModels.CollectionBas
|
||||
this.isCollectionExpanded = ko.observable<boolean>(true);
|
||||
}
|
||||
|
||||
public expandCollection(): Q.Promise<void> {
|
||||
public expandCollection(): void {
|
||||
if (this.isCollectionExpanded()) {
|
||||
return Q();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCollectionExpanded(true);
|
||||
@@ -55,8 +55,6 @@ export default class ResourceTokenCollection implements ViewModels.CollectionBas
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ResourceTree,
|
||||
});
|
||||
|
||||
return Q.resolve();
|
||||
}
|
||||
|
||||
public collapseCollection() {
|
||||
|
||||
@@ -15,12 +15,13 @@ import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
|
||||
import RefreshIcon from "../../../images/refresh-cosmos.svg";
|
||||
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
|
||||
import FileIcon from "../../../images/notebook/file-cosmos.svg";
|
||||
import PublishIcon from "../../../images/notebook/publish_content.svg";
|
||||
import { ArrayHashMap } from "../../Common/ArrayHashMap";
|
||||
import { NotebookUtil } from "../Notebook/NotebookUtil";
|
||||
import _ from "underscore";
|
||||
import { IPinnedRepo } from "../../Juno/JunoClient";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Areas } from "../../Common/Constants";
|
||||
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
||||
import GalleryIcon from "../../../images/GalleryIcon.svg";
|
||||
@@ -716,6 +717,23 @@ export class ResourceTreeAdapter implements ReactAdapter {
|
||||
},
|
||||
];
|
||||
|
||||
if (this.container.isGalleryPublishEnabled() && item.type === NotebookContentItemType.Notebook) {
|
||||
items.push({
|
||||
label: "Publish to gallery",
|
||||
iconSrc: PublishIcon,
|
||||
onClick: async () => {
|
||||
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
|
||||
source: Source.ResourceTreeMenu,
|
||||
});
|
||||
|
||||
const content = await this.container.readFile(item);
|
||||
if (content) {
|
||||
await this.container.publishNotebook(item.name, content);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// "Copy to ..." isn't needed if github locations are not available
|
||||
if (!this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
|
||||
items = items.filter((item) => item.label !== "Copy to ...");
|
||||
|
||||
5
src/GalleryViewer/GalleryViewer.less
Normal file
5
src/GalleryViewer/GalleryViewer.less
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "../../less/Common/Constants";
|
||||
|
||||
.standalone-gallery-root {
|
||||
background: @GalleryBackgroundColor;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import "bootstrap/dist/css/bootstrap.css";
|
||||
import "./GalleryViewer.less";
|
||||
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
|
||||
import { Text, Link } from "office-ui-fabric-react";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { initializeConfiguration } from "../ConfigContext";
|
||||
import { configContext, initializeConfiguration } from "../ConfigContext";
|
||||
import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent";
|
||||
import {
|
||||
GalleryAndNotebookViewerComponent,
|
||||
@@ -24,6 +25,7 @@ const onInit = async () => {
|
||||
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
|
||||
|
||||
const props: GalleryAndNotebookViewerComponentProps = {
|
||||
isGalleryPublishEnabled: configContext.ENABLE_GALLERY_PUBLISH,
|
||||
junoClient: new JunoClient(),
|
||||
selectedTab: galleryViewerProps.selectedTab || GalleryTab.OfficialSamples,
|
||||
sortBy: galleryViewerProps.sortBy || SortBy.MostViewed,
|
||||
@@ -31,7 +33,7 @@ const onInit = async () => {
|
||||
};
|
||||
|
||||
const element = (
|
||||
<>
|
||||
<div className="standalone-gallery-root">
|
||||
<header>
|
||||
<GalleryHeaderComponent />
|
||||
</header>
|
||||
@@ -52,7 +54,7 @@ const onInit = async () => {
|
||||
|
||||
<GalleryAndNotebookViewerComponent {...props} />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
ReactDOM.render(element, document.getElementById("galleryContent"));
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { AuthType } from "./AuthType";
|
||||
import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
|
||||
|
||||
type HostedConfig = AAD | ConnectionString | EncryptedToken | ResourceToken;
|
||||
export interface HostedExplorerChildFrame extends Window {
|
||||
hostedConfig: AAD | ConnectionString | EncryptedToken | ResourceToken;
|
||||
hostedConfig: HostedConfig;
|
||||
}
|
||||
|
||||
interface AAD {
|
||||
export interface AAD {
|
||||
authType: AuthType.AAD;
|
||||
databaseAccount: DatabaseAccount;
|
||||
authorizationToken: string;
|
||||
}
|
||||
|
||||
interface ConnectionString {
|
||||
export interface ConnectionString {
|
||||
authType: AuthType.ConnectionString;
|
||||
// Connection string uses still use encrypted token for Cassandra/Mongo APIs as they us the portal backend proxy
|
||||
encryptedToken: string;
|
||||
@@ -20,13 +21,13 @@ interface ConnectionString {
|
||||
masterKey?: string;
|
||||
}
|
||||
|
||||
interface EncryptedToken {
|
||||
export interface EncryptedToken {
|
||||
authType: AuthType.EncryptedToken;
|
||||
encryptedToken: string;
|
||||
encryptedTokenMetadata: AccessInputMetadata;
|
||||
}
|
||||
|
||||
interface ResourceToken {
|
||||
export interface ResourceToken {
|
||||
authType: AuthType.ResourceToken;
|
||||
resourceToken: string;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ describe("Gallery", () => {
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
||||
expect(window.fetch).toBeCalledWith(
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/downloads`,
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/${id}/downloads`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -248,7 +248,7 @@ describe("Gallery", () => {
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
||||
expect(window.fetch).toBeCalledWith(
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -270,13 +270,16 @@ describe("Gallery", () => {
|
||||
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
||||
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}/unfavorite`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
[authorizationHeader.header]: authorizationHeader.token,
|
||||
[HttpHeaders.contentType]: "application/json",
|
||||
},
|
||||
});
|
||||
expect(window.fetch).toBeCalledWith(
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/${id}/unfavorite`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
[authorizationHeader.header]: authorizationHeader.token,
|
||||
[HttpHeaders.contentType]: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("getFavoriteNotebooks", async () => {
|
||||
@@ -289,12 +292,15 @@ describe("Gallery", () => {
|
||||
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
||||
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/favorites`, {
|
||||
headers: {
|
||||
[authorizationHeader.header]: authorizationHeader.token,
|
||||
[HttpHeaders.contentType]: "application/json",
|
||||
},
|
||||
});
|
||||
expect(window.fetch).toBeCalledWith(
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/favorites`,
|
||||
{
|
||||
headers: {
|
||||
[authorizationHeader.header]: authorizationHeader.token,
|
||||
[HttpHeaders.contentType]: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("getPublishedNotebooks", async () => {
|
||||
@@ -308,7 +314,7 @@ describe("Gallery", () => {
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
||||
expect(window.fetch).toBeCalledWith(
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleSubscriptionId}/gallery/published`,
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/published`,
|
||||
{
|
||||
headers: {
|
||||
[authorizationHeader.header]: authorizationHeader.token,
|
||||
@@ -329,13 +335,16 @@ describe("Gallery", () => {
|
||||
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
||||
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
[authorizationHeader.header]: authorizationHeader.token,
|
||||
[HttpHeaders.contentType]: "application/json",
|
||||
},
|
||||
});
|
||||
expect(window.fetch).toBeCalledWith(
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
[authorizationHeader.header]: authorizationHeader.token,
|
||||
[HttpHeaders.contentType]: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("publishNotebook", async () => {
|
||||
@@ -364,7 +373,7 @@ describe("Gallery", () => {
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
expect(response.status).toBe(HttpStatusCodes.OK);
|
||||
expect(window.fetch).toBeCalledWith(
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleSubscriptionId}/${sampleDatabaseAccount.name}/gallery`,
|
||||
`${configContext.JUNO_ENDPOINT}/api/notebooks/subscriptions/${sampleSubscriptionId}/databaseAccounts/${sampleDatabaseAccount.name}/gallery`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
|
||||
@@ -80,7 +80,7 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async getPinnedRepos(scope: string): Promise<IJunoResponse<IPinnedRepo[]>> {
|
||||
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/pinnedrepos`, {
|
||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github/pinnedrepos`, {
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async updatePinnedRepos(repos: IPinnedRepo[]): Promise<IJunoResponse<undefined>> {
|
||||
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/pinnedrepos`, {
|
||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github/pinnedrepos`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(repos),
|
||||
headers: JunoClient.getHeaders(),
|
||||
@@ -120,7 +120,7 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async deleteGitHubInfo(): Promise<IJunoResponse<undefined>> {
|
||||
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github`, {
|
||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github`, {
|
||||
method: "DELETE",
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
@@ -135,9 +135,12 @@ export class JunoClient {
|
||||
const githubParams = JunoClient.getGitHubClientParams();
|
||||
githubParams.append("code", code);
|
||||
|
||||
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, {
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
const response = await window.fetch(
|
||||
`${this.getNotebooksSubscriptionIdAccountUrl()}/github/token?${githubParams.toString()}`,
|
||||
{
|
||||
headers: JunoClient.getHeaders(),
|
||||
}
|
||||
);
|
||||
|
||||
let data: IGitHubOAuthToken;
|
||||
const body = await response.text();
|
||||
@@ -159,10 +162,13 @@ export class JunoClient {
|
||||
const githubParams = JunoClient.getGitHubClientParams();
|
||||
githubParams.append("access_token", token);
|
||||
|
||||
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, {
|
||||
method: "DELETE",
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
const response = await window.fetch(
|
||||
`${this.getNotebooksSubscriptionIdAccountUrl()}/github/token?${githubParams.toString()}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: JunoClient.getHeaders(),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
@@ -179,11 +185,8 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async getPublicGalleryData(): Promise<IJunoResponse<IPublicGalleryData>> {
|
||||
const url = `${this.getNotebooksAccountUrl()}/gallery/public`;
|
||||
const response = await window.fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/public`;
|
||||
const response = await window.fetch(url, { headers: JunoClient.getHeaders() });
|
||||
|
||||
let data: IPublicGalleryData;
|
||||
if (response.status === HttpStatusCodes.OK) {
|
||||
@@ -197,7 +200,7 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async acceptCodeOfConduct(): Promise<IJunoResponse<boolean>> {
|
||||
const url = `${this.getNotebooksAccountUrl()}/gallery/acceptCodeOfConduct`;
|
||||
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/acceptCodeOfConduct`;
|
||||
const response = await window.fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: JunoClient.getHeaders(),
|
||||
@@ -215,11 +218,8 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
|
||||
const url = `${this.getNotebooksAccountUrl()}/gallery/isCodeOfConductAccepted`;
|
||||
const response = await window.fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/isCodeOfConductAccepted`;
|
||||
const response = await window.fetch(url, { headers: JunoClient.getHeaders() });
|
||||
|
||||
let data: boolean;
|
||||
if (response.status === HttpStatusCodes.OK) {
|
||||
@@ -277,7 +277,7 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async increaseNotebookDownloadCount(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
||||
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/downloads`, {
|
||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/downloads`, {
|
||||
method: "PATCH",
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
@@ -294,7 +294,7 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async favoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
||||
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/favorite`, {
|
||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/favorite`, {
|
||||
method: "PATCH",
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
@@ -311,7 +311,7 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async unfavoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
||||
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/unfavorite`, {
|
||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/unfavorite`, {
|
||||
method: "PATCH",
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
@@ -328,19 +328,19 @@ export class JunoClient {
|
||||
}
|
||||
|
||||
public async getFavoriteNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
|
||||
return await this.getNotebooks(`${this.getNotebooksUrl()}/gallery/favorites`, {
|
||||
return await this.getNotebooks(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/favorites`, {
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
public async getPublishedNotebooks(): Promise<IJunoResponse<IGalleryItem[]>> {
|
||||
return await this.getNotebooks(`${this.getNotebooksUrl()}/${this.getSubscriptionId()}/gallery/published`, {
|
||||
return await this.getNotebooks(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/published`, {
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
|
||||
const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}`, {
|
||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: JunoClient.getHeaders(),
|
||||
});
|
||||
@@ -365,22 +365,19 @@ export class JunoClient {
|
||||
content: string,
|
||||
isLinkInjectionEnabled: boolean
|
||||
): Promise<IJunoResponse<IGalleryItem>> {
|
||||
const response = await window.fetch(
|
||||
`${this.getNotebooksUrl()}/${this.getSubscriptionId()}/${this.getAccount()}/gallery`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: JunoClient.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
author,
|
||||
thumbnailUrl,
|
||||
content: JSON.parse(content),
|
||||
addLinkToNotebookViewer: isLinkInjectionEnabled,
|
||||
} as IPublishNotebookRequest),
|
||||
}
|
||||
);
|
||||
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery`, {
|
||||
method: "PUT",
|
||||
headers: JunoClient.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
author,
|
||||
thumbnailUrl,
|
||||
content: JSON.parse(content),
|
||||
addLinkToNotebookViewer: isLinkInjectionEnabled,
|
||||
} as IPublishNotebookRequest),
|
||||
});
|
||||
|
||||
let data: IGalleryItem;
|
||||
if (response.status === HttpStatusCodes.OK) {
|
||||
@@ -498,8 +495,8 @@ export class JunoClient {
|
||||
return userContext.subscriptionId;
|
||||
}
|
||||
|
||||
private getNotebooksAccountUrl(): string {
|
||||
return `${this.getNotebooksUrl()}/${this.getAccount()}`;
|
||||
private getNotebooksSubscriptionIdAccountUrl(): string {
|
||||
return `${this.getNotebooksUrl()}/subscriptions/${this.getSubscriptionId()}/databaseAccounts/${this.getAccount()}`;
|
||||
}
|
||||
|
||||
private getAnalyticsUrl(): string {
|
||||
|
||||
50
src/Localization/en/translations.json
Normal file
50
src/Localization/en/translations.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
239
src/Main.tsx
239
src/Main.tsx
@@ -14,6 +14,7 @@ import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less";
|
||||
import "./Explorer/Controls/DynamicList/DynamicListComponent.less";
|
||||
import "./Explorer/Controls/JsonEditor/JsonEditorComponent.less";
|
||||
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
|
||||
import "./Explorer/Panes/PanelComponent.less";
|
||||
import "../less/TableStyles/queryBuilder.less";
|
||||
import "../externals/jquery.dataTables.min.css";
|
||||
import "../less/TableStyles/fulldatatables.less";
|
||||
@@ -53,215 +54,40 @@ import "object.entries/auto";
|
||||
import "./Libs/is-integer-polyfill";
|
||||
import "url-polyfill/url-polyfill.min";
|
||||
|
||||
initializeIcons();
|
||||
|
||||
import { AuthType } from "./AuthType";
|
||||
|
||||
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
|
||||
import { applyExplorerBindings } from "./applyExplorerBindings";
|
||||
import { configContext, initializeConfiguration, Platform } from "./ConfigContext";
|
||||
import Explorer from "./Explorer/Explorer";
|
||||
import React, { useEffect } from "react";
|
||||
import { ExplorerParams } from "./Explorer/Explorer";
|
||||
import React, { useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import copyImage from "../images/Copy.svg";
|
||||
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
|
||||
import refreshImg from "../images/refresh-cosmos.svg";
|
||||
import arrowLeftImg from "../images/imgarrowlefticon.svg";
|
||||
import { KOCommentEnd, KOCommentIfStart } from "./koComment";
|
||||
import { updateUserContext } from "./UserContext";
|
||||
import { CollectionCreation } from "./Shared/Constants";
|
||||
import { extractFeatures } from "./Platform/Hosted/extractFeatures";
|
||||
import { emulatorAccount } from "./Platform/Emulator/emulatorAccount";
|
||||
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
|
||||
import {
|
||||
getDatabaseAccountKindFromExperience,
|
||||
getDatabaseAccountPropertiesFromMetadata,
|
||||
} from "./Platform/Hosted/HostedUtils";
|
||||
import { DefaultExperienceUtility } from "./Shared/DefaultExperienceUtility";
|
||||
import { parseResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
|
||||
import { AccountKind, DefaultAccountExperience, ServerIds } from "./Common/Constants";
|
||||
import { listKeys } from "./Utils/arm/generatedClients/2020-04-01/databaseAccounts";
|
||||
import { SelfServeType } from "./SelfServe/SelfServeUtils";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
import { useKnockoutExplorer } from "./hooks/useKnockoutExplorer";
|
||||
import { useSidePanel } from "./hooks/useSidePanel";
|
||||
import { NotificationConsoleComponent } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { PanelContainerComponent } from "./Explorer/Panes/PanelContainerComponent";
|
||||
|
||||
initializeIcons();
|
||||
|
||||
const App: React.FunctionComponent = () => {
|
||||
useEffect(() => {
|
||||
initializeConfiguration().then(async (config) => {
|
||||
let explorer: Explorer;
|
||||
if (config.platform === Platform.Hosted) {
|
||||
const win = (window as unknown) as HostedExplorerChildFrame;
|
||||
explorer = new Explorer();
|
||||
explorer.selfServeType(SelfServeType.none);
|
||||
if (win.hostedConfig.authType === AuthType.EncryptedToken) {
|
||||
// TODO: Remove window.authType
|
||||
window.authType = AuthType.EncryptedToken;
|
||||
// Impossible to tell if this is a try cosmos sub using an encrypted token
|
||||
explorer.isTryCosmosDBSubscription(false);
|
||||
updateUserContext({
|
||||
accessToken: encodeURIComponent(win.hostedConfig.encryptedToken),
|
||||
});
|
||||
const [isNotificationConsoleExpanded, setIsNotificationConsoleExpanded] = useState(false);
|
||||
const [notificationConsoleData, setNotificationConsoleData] = useState(undefined);
|
||||
//TODO: Refactor so we don't need to pass the id to remove a console data
|
||||
const [inProgressConsoleDataIdToBeDeleted, setInProgressConsoleDataIdToBeDeleted] = useState("");
|
||||
|
||||
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
|
||||
win.hostedConfig.encryptedTokenMetadata.apiKind
|
||||
);
|
||||
explorer.initDataExplorerWithFrameInputs({
|
||||
databaseAccount: {
|
||||
id: "",
|
||||
// id: Main._databaseAccountId,
|
||||
name: win.hostedConfig.encryptedTokenMetadata.accountName,
|
||||
kind: getDatabaseAccountKindFromExperience(apiExperience),
|
||||
properties: getDatabaseAccountPropertiesFromMetadata(win.hostedConfig.encryptedTokenMetadata),
|
||||
tags: [],
|
||||
},
|
||||
subscriptionId: undefined,
|
||||
resourceGroup: undefined,
|
||||
masterKey: undefined,
|
||||
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
|
||||
authorizationToken: undefined,
|
||||
features: extractFeatures(),
|
||||
csmEndpoint: undefined,
|
||||
dnsSuffix: undefined,
|
||||
serverId: ServerIds.productionPortal,
|
||||
extensionEndpoint: configContext.BACKEND_ENDPOINT,
|
||||
subscriptionType: CollectionCreation.DefaultSubscriptionType,
|
||||
quotaId: undefined,
|
||||
addCollectionDefaultFlight: explorer.flight(),
|
||||
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
|
||||
});
|
||||
explorer.isAccountReady(true);
|
||||
} else if (win.hostedConfig.authType === AuthType.ResourceToken) {
|
||||
window.authType = AuthType.ResourceToken;
|
||||
// Resource tokens can only be used with SQL API
|
||||
const apiExperience: string = DefaultAccountExperience.DocumentDB;
|
||||
const parsedResourceToken = parseResourceTokenConnectionString(win.hostedConfig.resourceToken);
|
||||
updateUserContext({
|
||||
resourceToken: parsedResourceToken.resourceToken,
|
||||
endpoint: parsedResourceToken.accountEndpoint,
|
||||
});
|
||||
explorer.resourceTokenDatabaseId(parsedResourceToken.databaseId);
|
||||
explorer.resourceTokenCollectionId(parsedResourceToken.collectionId);
|
||||
if (parsedResourceToken.partitionKey) {
|
||||
explorer.resourceTokenPartitionKey(parsedResourceToken.partitionKey);
|
||||
}
|
||||
explorer.initDataExplorerWithFrameInputs({
|
||||
databaseAccount: {
|
||||
id: "",
|
||||
name: parsedResourceToken.accountEndpoint,
|
||||
kind: AccountKind.GlobalDocumentDB,
|
||||
properties: { documentEndpoint: parsedResourceToken.accountEndpoint },
|
||||
tags: { defaultExperience: apiExperience },
|
||||
},
|
||||
subscriptionId: undefined,
|
||||
resourceGroup: undefined,
|
||||
masterKey: undefined,
|
||||
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
|
||||
authorizationToken: undefined,
|
||||
features: extractFeatures(),
|
||||
csmEndpoint: undefined,
|
||||
dnsSuffix: undefined,
|
||||
serverId: ServerIds.productionPortal,
|
||||
extensionEndpoint: configContext.BACKEND_ENDPOINT,
|
||||
subscriptionType: CollectionCreation.DefaultSubscriptionType,
|
||||
quotaId: undefined,
|
||||
addCollectionDefaultFlight: explorer.flight(),
|
||||
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
|
||||
isAuthWithresourceToken: true,
|
||||
});
|
||||
explorer.isAccountReady(true);
|
||||
explorer.isRefreshingExplorer(false);
|
||||
} else if (win.hostedConfig.authType === AuthType.ConnectionString) {
|
||||
// For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login
|
||||
window.authType = AuthType.EncryptedToken;
|
||||
// Impossible to tell if this is a try cosmos sub using an encrypted token
|
||||
explorer.isTryCosmosDBSubscription(false);
|
||||
updateUserContext({
|
||||
accessToken: encodeURIComponent(win.hostedConfig.encryptedToken),
|
||||
});
|
||||
const { isPanelOpen, panelContent, headerText, openSidePanel, closeSidePanel } = useSidePanel();
|
||||
|
||||
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
|
||||
win.hostedConfig.encryptedTokenMetadata.apiKind
|
||||
);
|
||||
explorer.initDataExplorerWithFrameInputs({
|
||||
databaseAccount: {
|
||||
id: "",
|
||||
// id: Main._databaseAccountId,
|
||||
name: win.hostedConfig.encryptedTokenMetadata.accountName,
|
||||
kind: getDatabaseAccountKindFromExperience(apiExperience),
|
||||
properties: getDatabaseAccountPropertiesFromMetadata(win.hostedConfig.encryptedTokenMetadata),
|
||||
tags: [],
|
||||
},
|
||||
subscriptionId: undefined,
|
||||
resourceGroup: undefined,
|
||||
masterKey: win.hostedConfig.masterKey,
|
||||
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
|
||||
authorizationToken: undefined,
|
||||
features: extractFeatures(),
|
||||
csmEndpoint: undefined,
|
||||
dnsSuffix: undefined,
|
||||
serverId: ServerIds.productionPortal,
|
||||
extensionEndpoint: configContext.BACKEND_ENDPOINT,
|
||||
subscriptionType: CollectionCreation.DefaultSubscriptionType,
|
||||
quotaId: undefined,
|
||||
addCollectionDefaultFlight: explorer.flight(),
|
||||
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
|
||||
});
|
||||
explorer.isAccountReady(true);
|
||||
} else if (win.hostedConfig.authType === AuthType.AAD) {
|
||||
window.authType = AuthType.AAD;
|
||||
const account = win.hostedConfig.databaseAccount;
|
||||
const accountResourceId = account.id;
|
||||
const subscriptionId = accountResourceId && accountResourceId.split("subscriptions/")[1].split("/")[0];
|
||||
const resourceGroup = accountResourceId && accountResourceId.split("resourceGroups/")[1].split("/")[0];
|
||||
updateUserContext({
|
||||
authorizationToken: `Bearer ${win.hostedConfig.authorizationToken}`,
|
||||
databaseAccount: win.hostedConfig.databaseAccount,
|
||||
});
|
||||
const keys = await listKeys(subscriptionId, resourceGroup, account.name);
|
||||
explorer.initDataExplorerWithFrameInputs({
|
||||
databaseAccount: account,
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
masterKey: keys.primaryMasterKey,
|
||||
hasWriteAccess: true, //TODO: 425017 - support read access
|
||||
authorizationToken: `Bearer ${win.hostedConfig.authorizationToken}`,
|
||||
features: extractFeatures(),
|
||||
csmEndpoint: undefined,
|
||||
dnsSuffix: undefined,
|
||||
serverId: ServerIds.productionPortal,
|
||||
extensionEndpoint: configContext.BACKEND_ENDPOINT,
|
||||
subscriptionType: CollectionCreation.DefaultSubscriptionType,
|
||||
quotaId: undefined,
|
||||
addCollectionDefaultFlight: explorer.flight(),
|
||||
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
|
||||
});
|
||||
explorer.isAccountReady(true);
|
||||
}
|
||||
} else if (config.platform === Platform.Emulator) {
|
||||
window.authType = AuthType.MasterKey;
|
||||
explorer = new Explorer();
|
||||
explorer.selfServeType(SelfServeType.none);
|
||||
explorer.databaseAccount(emulatorAccount);
|
||||
explorer.isAccountReady(true);
|
||||
} else if (config.platform === Platform.Portal) {
|
||||
window.authType = AuthType.AAD;
|
||||
explorer = new Explorer();
|
||||
|
||||
// In development mode, try to load the iframe message from session storage.
|
||||
// This allows webpack hot reload to funciton properly
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const initMessage = sessionStorage.getItem("portalDataExplorerInitMessage");
|
||||
if (initMessage) {
|
||||
const message = JSON.parse(initMessage);
|
||||
console.warn("Loaded cached portal iframe message from session storage");
|
||||
console.dir(message);
|
||||
explorer.initDataExplorerWithFrameInputs(message);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", explorer.handleMessage.bind(explorer), false);
|
||||
}
|
||||
applyExplorerBindings(explorer);
|
||||
});
|
||||
}, []);
|
||||
const explorerParams: ExplorerParams = {
|
||||
setIsNotificationConsoleExpanded,
|
||||
setNotificationConsoleData,
|
||||
setInProgressConsoleDataIdToBeDeleted,
|
||||
openSidePanel,
|
||||
closeSidePanel,
|
||||
};
|
||||
const config = useConfig();
|
||||
useKnockoutExplorer(config, explorerParams);
|
||||
|
||||
return (
|
||||
<div className="flexContainer">
|
||||
@@ -463,8 +289,14 @@ const App: React.FunctionComponent = () => {
|
||||
role="contentinfo"
|
||||
aria-label="Notification console"
|
||||
id="explorerNotificationConsole"
|
||||
data-bind="react: notificationConsoleComponentAdapter"
|
||||
/>
|
||||
>
|
||||
<NotificationConsoleComponent
|
||||
isConsoleExpanded={isNotificationConsoleExpanded}
|
||||
consoleData={notificationConsoleData}
|
||||
inProgressConsoleDataIdToBeDeleted={inProgressConsoleDataIdToBeDeleted}
|
||||
setIsConsoleExpanded={setIsNotificationConsoleExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Global loader - Start */}
|
||||
|
||||
@@ -485,6 +317,13 @@ const App: React.FunctionComponent = () => {
|
||||
</div>
|
||||
</div>
|
||||
{/* Global loader - End */}
|
||||
<PanelContainerComponent
|
||||
isOpen={isPanelOpen}
|
||||
panelContent={panelContent}
|
||||
headerText={headerText}
|
||||
closePanel={closeSidePanel}
|
||||
isConsoleExpanded={isNotificationConsoleExpanded}
|
||||
/>
|
||||
<div data-bind="react:uploadItemsPaneAdapter" />
|
||||
<div data-bind='component: { name: "add-database-pane", params: {data: addDatabasePane} }' />
|
||||
<div data-bind='component: { name: "add-collection-pane", params: { data: addCollectionPane} }' />
|
||||
|
||||
@@ -288,11 +288,9 @@ export class TabRouteHandler {
|
||||
private _openSprocTabForResource(databaseId: string, collectionId: string, sprocId: string): void {
|
||||
this._executeActionHelper(() => {
|
||||
const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId);
|
||||
collection &&
|
||||
collection.expandCollection().then(() => {
|
||||
const storedProcedure = collection && collection.findStoredProcedureWithId(sprocId);
|
||||
storedProcedure && storedProcedure.open();
|
||||
});
|
||||
collection && collection.expandCollection();
|
||||
const storedProcedure = collection && collection.findStoredProcedureWithId(sprocId);
|
||||
storedProcedure && storedProcedure.open();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -319,11 +317,9 @@ export class TabRouteHandler {
|
||||
private _openTriggerTabForResource(databaseId: string, collectionId: string, triggerId: string): void {
|
||||
this._executeActionHelper(() => {
|
||||
const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId);
|
||||
collection &&
|
||||
collection.expandCollection().then(() => {
|
||||
const trigger = collection && collection.findTriggerWithId(triggerId);
|
||||
trigger && trigger.open();
|
||||
});
|
||||
collection && collection.expandCollection();
|
||||
const trigger = collection && collection.findTriggerWithId(triggerId);
|
||||
trigger && trigger.open();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -350,11 +346,9 @@ export class TabRouteHandler {
|
||||
private _openUserDefinedFunctionTabForResource(databaseId: string, collectionId: string, udfId: string): void {
|
||||
this._executeActionHelper(() => {
|
||||
const collection: ViewModels.Collection = this._findMatchingCollectionForResource(databaseId, collectionId);
|
||||
collection &&
|
||||
collection.expandCollection().then(() => {
|
||||
const userDefinedFunction = collection && collection.findUserDefinedFunctionWithId(udfId);
|
||||
userDefinedFunction && userDefinedFunction.open();
|
||||
});
|
||||
collection && collection.expandCollection();
|
||||
const userDefinedFunction = collection && collection.findUserDefinedFunctionWithId(udfId);
|
||||
userDefinedFunction && userDefinedFunction.open();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Info } from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||
import { addPropertyToMap, buildSmartUiDescriptor } from "./SelfServeUtils";
|
||||
|
||||
export const IsDisplayable = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
buildSmartUiDescriptor(target.name, target.prototype);
|
||||
};
|
||||
};
|
||||
|
||||
export const ClassInfo = (info: (() => Promise<Info>) | Info): ClassDecorator => {
|
||||
return (target) => {
|
||||
addPropertyToMap(target.prototype, "root", target.name, "info", info);
|
||||
};
|
||||
};
|
||||
@@ -1,50 +1,64 @@
|
||||
import { ChoiceItem, Info, InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||
import { addPropertyToMap, CommonInputTypes } from "./SelfServeUtils";
|
||||
import { ChoiceItem, Description, Info, InputType, NumberUiType, SmartUiInput } from "./SelfServeTypes";
|
||||
import { addPropertyToMap, DecoratorProperties, buildSmartUiDescriptor } from "./SelfServeUtils";
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
interface Decorator {
|
||||
name: keyof CommonInputTypes;
|
||||
value: ValueOf<CommonInputTypes>;
|
||||
name: keyof DecoratorProperties;
|
||||
value: ValueOf<DecoratorProperties>;
|
||||
}
|
||||
|
||||
interface InputOptionsBase {
|
||||
label: string;
|
||||
labelTKey: string;
|
||||
}
|
||||
|
||||
export interface NumberInputOptions extends InputOptionsBase {
|
||||
min: (() => Promise<number>) | number;
|
||||
max: (() => Promise<number>) | number;
|
||||
step: (() => Promise<number>) | number;
|
||||
uiType: UiType;
|
||||
uiType: NumberUiType;
|
||||
}
|
||||
|
||||
export interface StringInputOptions extends InputOptionsBase {
|
||||
placeholder?: (() => Promise<string>) | string;
|
||||
placeholderTKey?: (() => Promise<string>) | string;
|
||||
}
|
||||
|
||||
export interface BooleanInputOptions extends InputOptionsBase {
|
||||
trueLabel: (() => Promise<string>) | string;
|
||||
falseLabel: (() => Promise<string>) | string;
|
||||
trueLabelTKey: (() => Promise<string>) | string;
|
||||
falseLabelTKey: (() => Promise<string>) | string;
|
||||
}
|
||||
|
||||
export interface ChoiceInputOptions extends InputOptionsBase {
|
||||
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
|
||||
placeholderTKey?: (() => Promise<string>) | string;
|
||||
}
|
||||
|
||||
type InputOptions = NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions;
|
||||
export interface DescriptionDisplayOptions {
|
||||
description?: (() => Promise<Description>) | Description;
|
||||
}
|
||||
|
||||
type InputOptions =
|
||||
| NumberInputOptions
|
||||
| StringInputOptions
|
||||
| BooleanInputOptions
|
||||
| ChoiceInputOptions
|
||||
| DescriptionDisplayOptions;
|
||||
|
||||
const isNumberInputOptions = (inputOptions: InputOptions): inputOptions is NumberInputOptions => {
|
||||
return "min" in inputOptions;
|
||||
};
|
||||
|
||||
const isBooleanInputOptions = (inputOptions: InputOptions): inputOptions is BooleanInputOptions => {
|
||||
return "trueLabel" in inputOptions;
|
||||
return "trueLabelTKey" in inputOptions;
|
||||
};
|
||||
|
||||
const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is ChoiceInputOptions => {
|
||||
return "choices" in inputOptions;
|
||||
};
|
||||
|
||||
const isDescriptionDisplayOptions = (inputOptions: InputOptions): inputOptions is DescriptionDisplayOptions => {
|
||||
return "description" in inputOptions;
|
||||
};
|
||||
|
||||
const addToMap = (...decorators: Decorator[]): PropertyDecorator => {
|
||||
return (target, property) => {
|
||||
let className = target.constructor.name;
|
||||
@@ -66,7 +80,7 @@ const addToMap = (...decorators: Decorator[]): PropertyDecorator => {
|
||||
};
|
||||
|
||||
export const OnChange = (
|
||||
onChange: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>
|
||||
onChange: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>
|
||||
): PropertyDecorator => {
|
||||
return addToMap({ name: "onChange", value: onChange });
|
||||
};
|
||||
@@ -78,7 +92,7 @@ export const PropertyInfo = (info: (() => Promise<Info>) | Info): PropertyDecora
|
||||
export const Values = (inputOptions: InputOptions): PropertyDecorator => {
|
||||
if (isNumberInputOptions(inputOptions)) {
|
||||
return addToMap(
|
||||
{ name: "label", value: inputOptions.label },
|
||||
{ name: "labelTKey", value: inputOptions.labelTKey },
|
||||
{ name: "min", value: inputOptions.min },
|
||||
{ name: "max", value: inputOptions.max },
|
||||
{ name: "step", value: inputOptions.step },
|
||||
@@ -86,16 +100,34 @@ export const Values = (inputOptions: InputOptions): PropertyDecorator => {
|
||||
);
|
||||
} else if (isBooleanInputOptions(inputOptions)) {
|
||||
return addToMap(
|
||||
{ name: "label", value: inputOptions.label },
|
||||
{ name: "trueLabel", value: inputOptions.trueLabel },
|
||||
{ name: "falseLabel", value: inputOptions.falseLabel }
|
||||
{ name: "labelTKey", value: inputOptions.labelTKey },
|
||||
{ name: "trueLabelTKey", value: inputOptions.trueLabelTKey },
|
||||
{ name: "falseLabelTKey", value: inputOptions.falseLabelTKey }
|
||||
);
|
||||
} else if (isChoiceInputOptions(inputOptions)) {
|
||||
return addToMap({ name: "label", value: inputOptions.label }, { name: "choices", value: inputOptions.choices });
|
||||
return addToMap(
|
||||
{ name: "labelTKey", value: inputOptions.labelTKey },
|
||||
{ name: "placeholderTKey", value: inputOptions.placeholderTKey },
|
||||
{ name: "choices", value: inputOptions.choices }
|
||||
);
|
||||
} else if (isDescriptionDisplayOptions(inputOptions)) {
|
||||
return addToMap({ name: "description", value: inputOptions.description });
|
||||
} else {
|
||||
return addToMap(
|
||||
{ name: "label", value: inputOptions.label },
|
||||
{ name: "placeholder", value: inputOptions.placeholder }
|
||||
{ name: "labelTKey", value: inputOptions.labelTKey },
|
||||
{ name: "placeholderTKey", value: inputOptions.placeholderTKey }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const IsDisplayable = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
buildSmartUiDescriptor(target.name, target.prototype);
|
||||
};
|
||||
};
|
||||
|
||||
export const ClassInfo = (info: (() => Promise<Info>) | Info): ClassDecorator => {
|
||||
return (target) => {
|
||||
addPropertyToMap(target.prototype, "root", target.name, "info", info);
|
||||
};
|
||||
};
|
||||
76
src/SelfServe/Example/SelfServeExample.rp.ts
Normal file
76
src/SelfServe/Example/SelfServeExample.rp.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { get } from "../../Utils/arm/generatedClients/2020-04-01/databaseAccounts";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { SessionStorageUtility } from "../../Shared/StorageUtility";
|
||||
import { RefreshResult } from "../SelfServeTypes";
|
||||
export enum Regions {
|
||||
NorthCentralUS = "NorthCentralUS",
|
||||
WestUS = "WestUS",
|
||||
EastUS2 = "EastUS2",
|
||||
}
|
||||
|
||||
export interface InitializeResponse {
|
||||
regions: Regions;
|
||||
enableLogging: boolean;
|
||||
accountName: string;
|
||||
collectionThroughput: number;
|
||||
dbThroughput: number;
|
||||
}
|
||||
|
||||
export const getMaxCollectionThroughput = async (): Promise<number> => {
|
||||
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 (
|
||||
regions: Regions,
|
||||
enableLogging: boolean,
|
||||
accountName: string,
|
||||
collectionThroughput: number,
|
||||
dbThoughput: number
|
||||
): Promise<void> => {
|
||||
SessionStorageUtility.setEntry("regions", regions);
|
||||
SessionStorageUtility.setEntry("enableLogging", enableLogging?.toString());
|
||||
SessionStorageUtility.setEntry("accountName", accountName);
|
||||
SessionStorageUtility.setEntry("collectionThroughput", collectionThroughput?.toString());
|
||||
SessionStorageUtility.setEntry("dbThroughput", dbThoughput?.toString());
|
||||
};
|
||||
|
||||
export const initialize = async (): Promise<InitializeResponse> => {
|
||||
const regions = Regions[SessionStorageUtility.getEntry("regions") as keyof typeof Regions];
|
||||
const enableLogging = SessionStorageUtility.getEntry("enableLogging") === "true";
|
||||
const accountName = SessionStorageUtility.getEntry("accountName");
|
||||
let collectionThroughput = parseInt(SessionStorageUtility.getEntry("collectionThroughput"));
|
||||
collectionThroughput = isNaN(collectionThroughput) ? undefined : collectionThroughput;
|
||||
let dbThroughput = parseInt(SessionStorageUtility.getEntry("dbThroughput"));
|
||||
dbThroughput = isNaN(dbThroughput) ? undefined : dbThroughput;
|
||||
return {
|
||||
regions: regions,
|
||||
enableLogging: enableLogging,
|
||||
accountName: accountName,
|
||||
collectionThroughput: collectionThroughput,
|
||||
dbThroughput: dbThroughput,
|
||||
};
|
||||
};
|
||||
|
||||
export const onRefreshSelfServeExample = async (): Promise<RefreshResult> => {
|
||||
const subscriptionId = userContext.subscriptionId;
|
||||
const resourceGroup = userContext.resourceGroup;
|
||||
const databaseAccountName = userContext.databaseAccount.name;
|
||||
const databaseAccountGetResults = await get(subscriptionId, resourceGroup, databaseAccountName);
|
||||
const isUpdateInProgress = databaseAccountGetResults.properties.provisioningState !== "Succeeded";
|
||||
return {
|
||||
isUpdateInProgress: isUpdateInProgress,
|
||||
notificationMessage: "RefreshMessage",
|
||||
};
|
||||
};
|
||||
@@ -1,37 +1,66 @@
|
||||
import { PropertyInfo, OnChange, Values } from "../PropertyDecorators";
|
||||
import { ClassInfo, IsDisplayable } from "../ClassDecorators";
|
||||
import { SelfServeBaseClass } from "../SelfServeUtils";
|
||||
import { ChoiceItem, Info, InputType, UiType } from "../../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||
import { SessionStorageUtility } from "../../Shared/StorageUtility";
|
||||
import { PropertyInfo, OnChange, Values, IsDisplayable, ClassInfo } from "../Decorators";
|
||||
import {
|
||||
ChoiceItem,
|
||||
Info,
|
||||
InputType,
|
||||
NumberUiType,
|
||||
RefreshResult,
|
||||
SelfServeBaseClass,
|
||||
SelfServeNotification,
|
||||
SelfServeNotificationType,
|
||||
SmartUiInput,
|
||||
} from "../SelfServeTypes";
|
||||
import {
|
||||
onRefreshSelfServeExample,
|
||||
Regions,
|
||||
update,
|
||||
initialize,
|
||||
getMinDatabaseThroughput,
|
||||
getMaxDatabaseThroughput,
|
||||
getMinCollectionThroughput,
|
||||
getMaxCollectionThroughput,
|
||||
} from "./SelfServeExample.rp";
|
||||
|
||||
export enum Regions {
|
||||
NorthCentralUS = "NCUS",
|
||||
WestUS = "WUS",
|
||||
EastUS2 = "EUS2",
|
||||
}
|
||||
|
||||
export const regionDropdownItems: ChoiceItem[] = [
|
||||
const regionDropdownItems: ChoiceItem[] = [
|
||||
{ label: "North Central US", key: Regions.NorthCentralUS },
|
||||
{ label: "West US", key: Regions.WestUS },
|
||||
{ label: "East US 2", key: Regions.EastUS2 },
|
||||
];
|
||||
|
||||
export const selfServeExampleInfo: Info = {
|
||||
message: "This is a self serve class",
|
||||
const selfServeExampleInfo: Info = {
|
||||
messageTKey: "ClassInfo",
|
||||
};
|
||||
|
||||
export const regionDropdownInfo: Info = {
|
||||
message: "More regions can be added in the future.",
|
||||
const regionDropdownInfo: Info = {
|
||||
messageTKey: "RegionDropdownInfo",
|
||||
};
|
||||
|
||||
const onDbThroughputChange = (currentState: Map<string, InputType>, newValue: InputType): Map<string, InputType> => {
|
||||
currentState.set("dbThroughput", newValue);
|
||||
currentState.set("collectionThroughput", newValue);
|
||||
const onRegionsChange = (currentState: Map<string, SmartUiInput>, newValue: InputType): Map<string, SmartUiInput> => {
|
||||
currentState.set("regions", { value: newValue });
|
||||
const currentEnableLogging = currentState.get("enableLogging");
|
||||
if (newValue === Regions.NorthCentralUS) {
|
||||
currentState.set("enableLogging", { value: false, disabled: true });
|
||||
} else {
|
||||
currentState.set("enableLogging", { value: currentEnableLogging.value, disabled: false });
|
||||
}
|
||||
return currentState;
|
||||
};
|
||||
|
||||
const initializeMaxThroughput = async (): Promise<number> => {
|
||||
return 10000;
|
||||
const onEnableDbLevelThroughputChange = (
|
||||
currentState: Map<string, SmartUiInput>,
|
||||
newValue: InputType
|
||||
): Map<string, SmartUiInput> => {
|
||||
currentState.set("enableDbLevelThroughput", { value: newValue });
|
||||
const currentDbThroughput = currentState.get("dbThroughput");
|
||||
const isDbThroughputHidden = newValue === undefined || !(newValue as boolean);
|
||||
currentState.set("dbThroughput", { value: currentDbThroughput.value, hidden: isDbThroughputHidden });
|
||||
return currentState;
|
||||
};
|
||||
|
||||
const validate = (currentvalues: Map<string, SmartUiInput>): void => {
|
||||
if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) {
|
||||
throw new Error("ValidationError");
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -40,11 +69,15 @@ const initializeMaxThroughput = async (): Promise<number> => {
|
||||
Each self serve class
|
||||
- Needs to extends the SelfServeBase class.
|
||||
- Needs to have the @IsDisplayable() decorator to tell the compiler that UI needs to be generated from this class.
|
||||
- Needs to define an onSubmit() function, a callback for when the submit button is clicked.
|
||||
- Needs to define an onSave() function, a callback for when the submit button is clicked.
|
||||
- Needs to define an initialize() function, to set default values for the inputs.
|
||||
- Needs to define an onRefresh() function, a callback for when the refresh button is clicked.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
/*
|
||||
@@ -61,25 +94,46 @@ const initializeMaxThroughput = async (): Promise<number> => {
|
||||
@ClassInfo(selfServeExampleInfo)
|
||||
export default class SelfServeExample extends SelfServeBaseClass {
|
||||
/*
|
||||
onSubmit()
|
||||
onRefresh()
|
||||
- role : Callback that is triggerrd when the refresh button is clicked. You should perform the your rest API
|
||||
call to check if the update action is completed.
|
||||
- returns:
|
||||
RefreshResult -
|
||||
isComponentUpdating: Indicated if the state is still being updated
|
||||
notificationMessage: Notification message to be shown in case the component is still being updated
|
||||
i.e, isComponentUpdating is true
|
||||
*/
|
||||
public onRefresh = async (): Promise<RefreshResult> => {
|
||||
return onRefreshSelfServeExample();
|
||||
};
|
||||
|
||||
/*
|
||||
onSave()
|
||||
- input: (currentValues: Map<string, InputType>) => Promise<void>
|
||||
- role: Callback that is triggerred when the submit button is clicked. You should perform your rest API
|
||||
calls here using the data from the different inputs passed as a Map to this callback function.
|
||||
|
||||
In this example, the onSubmit callback simply sets the value for keys corresponding to the field name
|
||||
In this example, the onSave callback simply sets the value for keys corresponding to the field name
|
||||
in the SessionStorage.
|
||||
- returns: SelfServeNotification -
|
||||
message: The message to be displayed in the message bar after the onSave is completed
|
||||
type: The type of message bar to be used (info, warning, error)
|
||||
*/
|
||||
public onSubmit = async (currentValues: Map<string, InputType>): Promise<void> => {
|
||||
SessionStorageUtility.setEntry("regions", currentValues.get("regions")?.toString());
|
||||
SessionStorageUtility.setEntry("enableLogging", currentValues.get("enableLogging")?.toString());
|
||||
SessionStorageUtility.setEntry("accountName", currentValues.get("accountName")?.toString());
|
||||
SessionStorageUtility.setEntry("dbThroughput", currentValues.get("dbThroughput")?.toString());
|
||||
SessionStorageUtility.setEntry("collectionThroughput", currentValues.get("collectionThroughput")?.toString());
|
||||
public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<SelfServeNotification> => {
|
||||
validate(currentValues);
|
||||
const regions = Regions[currentValues.get("regions")?.value as keyof typeof Regions];
|
||||
const enableLogging = currentValues.get("enableLogging")?.value as boolean;
|
||||
const accountName = currentValues.get("accountName")?.value as string;
|
||||
const collectionThroughput = currentValues.get("collectionThroughput")?.value as number;
|
||||
const enableDbLevelThroughput = currentValues.get("enableDbLevelThroughput")?.value as boolean;
|
||||
let dbThroughput = currentValues.get("dbThroughput")?.value as number;
|
||||
dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined;
|
||||
await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput);
|
||||
return { message: "SubmissionMessage", type: SelfServeNotificationType.info };
|
||||
};
|
||||
|
||||
/*
|
||||
initialize()
|
||||
- input: () => Promise<Map<string, InputType>>
|
||||
- role: Set default values for the properties of this class.
|
||||
|
||||
The properties of this class (namely regions, enableLogging, accountName, dbThroughput, collectionThroughput),
|
||||
@@ -87,24 +141,46 @@ export default class SelfServeExample extends SelfServeBaseClass {
|
||||
defaults can be set by setting values in a Map corresponding to the field's name.
|
||||
|
||||
Typically, you can make rest calls in the async initialize function, to fetch the initial values for
|
||||
these fields. This is called after the onSubmit callback, to reinitialize the defaults.
|
||||
these fields. This is called after the onSave callback, to reinitialize the defaults.
|
||||
|
||||
In this example, the initialize function simply reads the SessionStorage to fetch the default values
|
||||
for these fields. These are then set when the changes are submitted.
|
||||
- returns: () => Promise<Map<string, InputType>>
|
||||
*/
|
||||
public initialize = async (): Promise<Map<string, InputType>> => {
|
||||
const defaults = new Map<string, InputType>();
|
||||
defaults.set("regions", SessionStorageUtility.getEntry("regions"));
|
||||
defaults.set("enableLogging", SessionStorageUtility.getEntry("enableLogging") === "true");
|
||||
const stringInput = SessionStorageUtility.getEntry("accountName");
|
||||
defaults.set("accountName", stringInput ? stringInput : "");
|
||||
const numberSliderInput = parseInt(SessionStorageUtility.getEntry("dbThroughput"));
|
||||
defaults.set("dbThroughput", isNaN(numberSliderInput) ? 1 : numberSliderInput);
|
||||
const numberSpinnerInput = parseInt(SessionStorageUtility.getEntry("collectionThroughput"));
|
||||
defaults.set("collectionThroughput", isNaN(numberSpinnerInput) ? 1 : numberSpinnerInput);
|
||||
public initialize = async (): Promise<Map<string, SmartUiInput>> => {
|
||||
const initializeResponse = await initialize();
|
||||
const defaults = new Map<string, SmartUiInput>();
|
||||
defaults.set("regions", { value: initializeResponse.regions });
|
||||
defaults.set("enableLogging", { value: initializeResponse.enableLogging });
|
||||
const accountName = initializeResponse.accountName;
|
||||
defaults.set("accountName", { value: accountName ? accountName : "" });
|
||||
defaults.set("collectionThroughput", { value: initializeResponse.collectionThroughput });
|
||||
const enableDbLevelThroughput = !!initializeResponse.dbThroughput;
|
||||
defaults.set("enableDbLevelThroughput", { value: enableDbLevelThroughput });
|
||||
defaults.set("dbThroughput", { value: initializeResponse.dbThroughput, hidden: !enableDbLevelThroughput });
|
||||
return defaults;
|
||||
};
|
||||
|
||||
/*
|
||||
@Values() :
|
||||
- input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions | DescriptionDisplay
|
||||
- role: Specifies the required options to display the property as
|
||||
a) TextBox for text input
|
||||
b) Spinner/Slider for number input
|
||||
c) Radio buton/Toggle for boolean input
|
||||
d) Dropdown for choice input
|
||||
e) Text (with optional hyperlink) for descriptions
|
||||
*/
|
||||
@Values({
|
||||
description: {
|
||||
textTKey: "DescriptionText",
|
||||
link: {
|
||||
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
|
||||
textTKey: "DecriptionLinkText",
|
||||
},
|
||||
},
|
||||
})
|
||||
description: string;
|
||||
/*
|
||||
@PropertyInfo()
|
||||
- optional
|
||||
@@ -114,54 +190,64 @@ export default class SelfServeExample extends SelfServeBaseClass {
|
||||
@PropertyInfo(regionDropdownInfo)
|
||||
|
||||
/*
|
||||
@Values() :
|
||||
- input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions
|
||||
- role: Specifies the required options to display the property as TextBox, Number Spinner/Slider, Radio buton or Dropdown.
|
||||
@OnChange()
|
||||
- optional
|
||||
- input: (currentValues: Map<string, InputType>, newValue: InputType) => Map<string, InputType>
|
||||
- role: Takes a Map of current values and the newValue for this property as inputs. This is called when a property,
|
||||
say prop1, changes its value in the UI. This can be used to
|
||||
a) Change the value (and reflect it in the UI) for prop2 based on prop1.
|
||||
b) Change the visibility for prop2 in the UI, based on prop1
|
||||
|
||||
The new Map of propertyName -> value is returned.
|
||||
|
||||
In this example, the onRegionsChange function sets the enableLogging property to false (and disables
|
||||
the corresponsing toggle UI) when "regions" is set to "North Central US", and enables the toggle for
|
||||
any other value of "regions"
|
||||
*/
|
||||
@Values({ label: "Regions", choices: regionDropdownItems })
|
||||
@OnChange(onRegionsChange)
|
||||
@Values({ labelTKey: "Regions", choices: regionDropdownItems, placeholderTKey: "RegionsPlaceholder" })
|
||||
regions: ChoiceItem;
|
||||
|
||||
@Values({
|
||||
label: "Enable Logging",
|
||||
trueLabel: "Enable",
|
||||
falseLabel: "Disable",
|
||||
labelTKey: "Enable Logging",
|
||||
trueLabelTKey: "Enable",
|
||||
falseLabelTKey: "Disable",
|
||||
})
|
||||
enableLogging: boolean;
|
||||
|
||||
@Values({
|
||||
label: "Account Name",
|
||||
placeholder: "Enter the account name",
|
||||
labelTKey: "Account Name",
|
||||
placeholderTKey: "AccountNamePlaceHolder",
|
||||
})
|
||||
accountName: string;
|
||||
|
||||
/*
|
||||
@OnChange()
|
||||
- optional
|
||||
- input: (currentValues: Map<string, InputType>, newValue: InputType) => Map<string, InputType>
|
||||
- role: Takes a Map of current values and the newValue for this property as inputs. This is called when a property
|
||||
changes its value in the UI. This can be used to change other input values based on some other input.
|
||||
|
||||
The new Map of propertyName -> value is returned.
|
||||
|
||||
In this example, the onDbThroughputChange function sets the collectionThroughput to the same value as the dbThroughput
|
||||
when the slider in moved in the UI.
|
||||
*/
|
||||
@OnChange(onDbThroughputChange)
|
||||
@Values({
|
||||
label: "Database Throughput",
|
||||
min: 400,
|
||||
max: initializeMaxThroughput,
|
||||
labelTKey: "Collection Throughput",
|
||||
min: getMinCollectionThroughput,
|
||||
max: getMaxCollectionThroughput,
|
||||
step: 100,
|
||||
uiType: UiType.Slider,
|
||||
})
|
||||
dbThroughput: number;
|
||||
|
||||
@Values({
|
||||
label: "Collection Throughput",
|
||||
min: 400,
|
||||
max: initializeMaxThroughput,
|
||||
step: 100,
|
||||
uiType: UiType.Spinner,
|
||||
uiType: NumberUiType.Spinner,
|
||||
})
|
||||
collectionThroughput: number;
|
||||
|
||||
/*
|
||||
In this example, the onEnableDbLevelThroughputChange function makes the dbThroughput property visible when
|
||||
enableDbLevelThroughput, a boolean, is set to true and hides dbThroughput property when it is set to false.
|
||||
*/
|
||||
@OnChange(onEnableDbLevelThroughputChange)
|
||||
@Values({
|
||||
labelTKey: "Enable DB level throughput",
|
||||
trueLabelTKey: "Enable",
|
||||
falseLabelTKey: "Disable",
|
||||
})
|
||||
enableDbLevelThroughput: boolean;
|
||||
|
||||
@Values({
|
||||
labelTKey: "Database Throughput",
|
||||
min: getMinDatabaseThroughput,
|
||||
max: getMaxDatabaseThroughput,
|
||||
step: 100,
|
||||
uiType: NumberUiType.Slider,
|
||||
})
|
||||
dbThroughput: number;
|
||||
}
|
||||
|
||||
@@ -1,50 +1,63 @@
|
||||
import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import { SelfServeDescriptor, SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
|
||||
import { InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||
import { SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
|
||||
import { NumberUiType, SelfServeDescriptor, SelfServeNotificationType, SmartUiInput } from "./SelfServeTypes";
|
||||
|
||||
describe("SelfServeComponent", () => {
|
||||
const defaultValues = new Map<string, InputType>([
|
||||
["throughput", "450"],
|
||||
["analyticalStore", "false"],
|
||||
["database", "db2"],
|
||||
const defaultValues = new Map<string, SmartUiInput>([
|
||||
["throughput", { value: 450 }],
|
||||
["analyticalStore", { value: false }],
|
||||
["database", { value: "db2" }],
|
||||
]);
|
||||
const initializeMock = jest.fn(async () => defaultValues);
|
||||
const onSubmitMock = jest.fn(async () => {
|
||||
return;
|
||||
const updatedValues = new Map<string, SmartUiInput>([
|
||||
["throughput", { value: 460 }],
|
||||
["analyticalStore", { value: true }],
|
||||
["database", { value: "db2" }],
|
||||
]);
|
||||
|
||||
const initializeMock = jest.fn(async () => new Map(defaultValues));
|
||||
const onSaveMock = jest.fn(async () => {
|
||||
return { message: "submitted successfully", type: SelfServeNotificationType.info };
|
||||
});
|
||||
const onRefreshMock = jest.fn(async () => {
|
||||
return { isUpdateInProgress: false, notificationMessage: "refresh performed successfully" };
|
||||
});
|
||||
const onRefreshIsUpdatingMock = jest.fn(async () => {
|
||||
return { isUpdateInProgress: true, notificationMessage: "refresh performed successfully" };
|
||||
});
|
||||
|
||||
const exampleData: SelfServeDescriptor = {
|
||||
initialize: initializeMock,
|
||||
onSubmit: onSubmitMock,
|
||||
inputNames: ["throughput", "containerId", "analyticalStore", "database"],
|
||||
onSave: onSaveMock,
|
||||
onRefresh: onRefreshMock,
|
||||
inputNames: ["throughput", "analyticalStore", "database"],
|
||||
root: {
|
||||
id: "root",
|
||||
info: {
|
||||
message: "Start at $24/mo per database",
|
||||
messageTKey: "Start at $24/mo per database",
|
||||
link: {
|
||||
href: "https://aka.ms/azure-cosmos-db-pricing",
|
||||
text: "More Details",
|
||||
textTKey: "More Details",
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: "throughput",
|
||||
input: {
|
||||
label: "Throughput (input)",
|
||||
labelTKey: "Throughput (input)",
|
||||
dataFieldName: "throughput",
|
||||
type: "number",
|
||||
min: 400,
|
||||
max: 500,
|
||||
step: 10,
|
||||
defaultValue: 400,
|
||||
uiType: UiType.Spinner,
|
||||
uiType: NumberUiType.Spinner,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "containerId",
|
||||
input: {
|
||||
label: "Container id",
|
||||
labelTKey: "Container id",
|
||||
dataFieldName: "containerId",
|
||||
type: "string",
|
||||
},
|
||||
@@ -52,9 +65,9 @@ describe("SelfServeComponent", () => {
|
||||
{
|
||||
id: "analyticalStore",
|
||||
input: {
|
||||
label: "Analytical Store",
|
||||
trueLabel: "Enabled",
|
||||
falseLabel: "Disabled",
|
||||
labelTKey: "Analytical Store",
|
||||
trueLabelTKey: "Enabled",
|
||||
falseLabelTKey: "Disabled",
|
||||
defaultValue: true,
|
||||
dataFieldName: "analyticalStore",
|
||||
type: "boolean",
|
||||
@@ -63,7 +76,7 @@ describe("SelfServeComponent", () => {
|
||||
{
|
||||
id: "database",
|
||||
input: {
|
||||
label: "Database",
|
||||
labelTKey: "Database",
|
||||
dataFieldName: "database",
|
||||
type: "object",
|
||||
choices: [
|
||||
@@ -78,27 +91,109 @@ describe("SelfServeComponent", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const verifyDefaultsSet = (currentValues: Map<string, InputType>): void => {
|
||||
for (const key of currentValues.keys()) {
|
||||
if (defaultValues.has(key)) {
|
||||
expect(defaultValues.get(key)).toEqual(currentValues.get(key));
|
||||
}
|
||||
const isEqual = (source: Map<string, SmartUiInput>, target: Map<string, SmartUiInput>): void => {
|
||||
expect(target.size).toEqual(source.size);
|
||||
for (const key of source.keys()) {
|
||||
expect(target.get(key)).toEqual(source.get(key));
|
||||
}
|
||||
};
|
||||
|
||||
it("should render", async () => {
|
||||
it("should render and honor save, discard, refresh actions", async () => {
|
||||
const wrapper = shallow(<SelfServeComponent descriptor={exampleData} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
// initialize() should be called and defaults should be set when component is mounted
|
||||
expect(initializeMock).toHaveBeenCalled();
|
||||
const state = wrapper.state() as SelfServeComponentState;
|
||||
verifyDefaultsSet(state.currentValues);
|
||||
// initialize() and onRefresh() should be called and defaults should be set when component is mounted
|
||||
expect(initializeMock).toHaveBeenCalledTimes(1);
|
||||
expect(onRefreshMock).toHaveBeenCalledTimes(1);
|
||||
let state = wrapper.state() as SelfServeComponentState;
|
||||
isEqual(state.currentValues, defaultValues);
|
||||
|
||||
// onSubmit() must be called when submit button is clicked
|
||||
const submitButton = wrapper.find("#submitButton");
|
||||
submitButton.simulate("click");
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
// when currentValues and baselineValues differ, save and discard should not be disabled
|
||||
wrapper.setState({ currentValues: updatedValues });
|
||||
wrapper.update();
|
||||
state = wrapper.state() as SelfServeComponentState;
|
||||
isEqual(state.currentValues, updatedValues);
|
||||
const selfServeComponent = wrapper.instance() as SelfServeComponent;
|
||||
expect(selfServeComponent.isSaveButtonDisabled()).toBeFalsy();
|
||||
expect(selfServeComponent.isDiscardButtonDisabled()).toBeFalsy();
|
||||
|
||||
// when errors exist, save is disabled but discard is enabled
|
||||
wrapper.setState({ hasErrors: true });
|
||||
wrapper.update();
|
||||
state = wrapper.state() as SelfServeComponentState;
|
||||
expect(selfServeComponent.isSaveButtonDisabled()).toBeTruthy();
|
||||
expect(selfServeComponent.isDiscardButtonDisabled()).toBeFalsy();
|
||||
|
||||
// discard resets currentValues to baselineValues
|
||||
selfServeComponent.discard();
|
||||
state = wrapper.state() as SelfServeComponentState;
|
||||
isEqual(state.currentValues, defaultValues);
|
||||
isEqual(state.currentValues, state.baselineValues);
|
||||
|
||||
// resetBaselineValues sets baselineValues to currentValues
|
||||
wrapper.setState({ baselineValues: updatedValues });
|
||||
wrapper.update();
|
||||
state = wrapper.state() as SelfServeComponentState;
|
||||
isEqual(state.baselineValues, updatedValues);
|
||||
selfServeComponent.resetBaselineValues();
|
||||
state = wrapper.state() as SelfServeComponentState;
|
||||
isEqual(state.baselineValues, defaultValues);
|
||||
isEqual(state.currentValues, state.baselineValues);
|
||||
|
||||
// clicking refresh calls onRefresh. If component is not updating, it calls initialize() as well
|
||||
selfServeComponent.onRefreshClicked();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(onRefreshMock).toHaveBeenCalledTimes(2);
|
||||
expect(initializeMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
selfServeComponent.onSaveButtonClick();
|
||||
expect(onSaveMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("getResolvedValue", async () => {
|
||||
const wrapper = shallow(<SelfServeComponent descriptor={exampleData} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const selfServeComponent = wrapper.instance() as SelfServeComponent;
|
||||
|
||||
const numberResult = 1;
|
||||
const numberPromise = async (): Promise<number> => {
|
||||
return numberResult;
|
||||
};
|
||||
expect(await selfServeComponent.getResolvedValue(numberResult)).toEqual(numberResult);
|
||||
expect(await selfServeComponent.getResolvedValue(numberPromise)).toEqual(numberResult);
|
||||
|
||||
const stringResult = "result";
|
||||
const stringPromise = async (): Promise<string> => {
|
||||
return stringResult;
|
||||
};
|
||||
expect(await selfServeComponent.getResolvedValue(stringResult)).toEqual(stringResult);
|
||||
expect(await selfServeComponent.getResolvedValue(stringPromise)).toEqual(stringResult);
|
||||
});
|
||||
|
||||
it("message bar and spinner snapshots", async () => {
|
||||
const newDescriptor = { ...exampleData, onRefresh: onRefreshIsUpdatingMock };
|
||||
let wrapper = shallow(<SelfServeComponent descriptor={newDescriptor} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
let selfServeComponent = wrapper.instance() as SelfServeComponent;
|
||||
selfServeComponent.onSaveButtonClick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
newDescriptor.onRefresh = onRefreshMock;
|
||||
wrapper = shallow(<SelfServeComponent descriptor={newDescriptor} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
selfServeComponent = wrapper.instance() as SelfServeComponent;
|
||||
selfServeComponent.onSaveButtonClick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
wrapper.setState({ isInitializing: true });
|
||||
wrapper.update();
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
wrapper.setState({ compileErrorMessage: "sample error message" });
|
||||
wrapper.update();
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +1,34 @@
|
||||
import React from "react";
|
||||
import { IStackTokens, PrimaryButton, Spinner, SpinnerSize, Stack } from "office-ui-fabric-react";
|
||||
import {
|
||||
ChoiceItem,
|
||||
CommandBar,
|
||||
ICommandBarItemProps,
|
||||
IStackTokens,
|
||||
MessageBar,
|
||||
MessageBarType,
|
||||
Spinner,
|
||||
SpinnerSize,
|
||||
Stack,
|
||||
} from "office-ui-fabric-react";
|
||||
import {
|
||||
AnyDisplay,
|
||||
Node,
|
||||
InputType,
|
||||
InputTypeValue,
|
||||
SmartUiComponent,
|
||||
UiType,
|
||||
SmartUiDescriptor,
|
||||
Info,
|
||||
} from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||
|
||||
export interface BaseInput {
|
||||
label: (() => Promise<string>) | string;
|
||||
dataFieldName: string;
|
||||
type: InputTypeValue;
|
||||
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
|
||||
placeholder?: (() => Promise<string>) | string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface NumberInput extends BaseInput {
|
||||
min: (() => Promise<number>) | number;
|
||||
max: (() => Promise<number>) | number;
|
||||
step: (() => Promise<number>) | number;
|
||||
defaultValue?: number;
|
||||
uiType: UiType;
|
||||
}
|
||||
|
||||
export interface BooleanInput extends BaseInput {
|
||||
trueLabel: (() => Promise<string>) | string;
|
||||
falseLabel: (() => Promise<string>) | string;
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
|
||||
export interface StringInput extends BaseInput {
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface ChoiceInput extends BaseInput {
|
||||
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
|
||||
defaultKey?: string;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
info?: (() => Promise<Info>) | Info;
|
||||
input?: AnyInput;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
export interface SelfServeDescriptor {
|
||||
root: Node;
|
||||
initialize?: () => Promise<Map<string, InputType>>;
|
||||
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
|
||||
inputNames?: string[];
|
||||
}
|
||||
|
||||
export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
|
||||
RefreshResult,
|
||||
SelfServeDescriptor,
|
||||
SelfServeNotification,
|
||||
SmartUiInput,
|
||||
DescriptionDisplay,
|
||||
StringInput,
|
||||
NumberInput,
|
||||
BooleanInput,
|
||||
ChoiceInput,
|
||||
SelfServeNotificationType,
|
||||
} from "./SelfServeTypes";
|
||||
import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||
import { getMessageBarType } from "./SelfServeUtils";
|
||||
import { Translation } from "react-i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import "../i18n";
|
||||
|
||||
export interface SelfServeComponentProps {
|
||||
descriptor: SelfServeDescriptor;
|
||||
@@ -64,13 +36,20 @@ export interface SelfServeComponentProps {
|
||||
|
||||
export interface SelfServeComponentState {
|
||||
root: SelfServeDescriptor;
|
||||
currentValues: Map<string, InputType>;
|
||||
baselineValues: Map<string, InputType>;
|
||||
isRefreshing: boolean;
|
||||
currentValues: Map<string, SmartUiInput>;
|
||||
baselineValues: Map<string, SmartUiInput>;
|
||||
isInitializing: boolean;
|
||||
hasErrors: boolean;
|
||||
compileErrorMessage: string;
|
||||
notification: SelfServeNotification;
|
||||
refreshResult: RefreshResult;
|
||||
}
|
||||
|
||||
export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> {
|
||||
private smartUiGeneratorClassName: string;
|
||||
|
||||
componentDidMount(): void {
|
||||
this.performRefresh();
|
||||
this.initializeSmartUiComponent();
|
||||
}
|
||||
|
||||
@@ -80,62 +59,109 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
|
||||
root: this.props.descriptor,
|
||||
currentValues: new Map(),
|
||||
baselineValues: new Map(),
|
||||
isRefreshing: false,
|
||||
isInitializing: true,
|
||||
hasErrors: false,
|
||||
compileErrorMessage: undefined,
|
||||
notification: undefined,
|
||||
refreshResult: undefined,
|
||||
};
|
||||
this.smartUiGeneratorClassName = this.props.descriptor.root.id;
|
||||
}
|
||||
|
||||
private onError = (hasErrors: boolean): void => {
|
||||
this.setState({ hasErrors });
|
||||
};
|
||||
|
||||
private initializeSmartUiComponent = async (): Promise<void> => {
|
||||
this.setState({ isRefreshing: true });
|
||||
await this.initializeSmartUiNode(this.props.descriptor.root);
|
||||
this.setState({ isInitializing: true });
|
||||
await this.setDefaults();
|
||||
this.setState({ isRefreshing: false });
|
||||
const { currentValues, baselineValues } = this.state;
|
||||
await this.initializeSmartUiNode(this.props.descriptor.root, currentValues, baselineValues);
|
||||
this.setState({ isInitializing: false, currentValues, baselineValues });
|
||||
};
|
||||
|
||||
private setDefaults = async (): Promise<void> => {
|
||||
this.setState({ isRefreshing: true });
|
||||
let { currentValues, baselineValues } = this.state;
|
||||
|
||||
const initialValues = await this.props.descriptor.initialize();
|
||||
for (const key of initialValues.keys()) {
|
||||
if (this.props.descriptor.inputNames.indexOf(key) === -1) {
|
||||
this.setState({ isRefreshing: false });
|
||||
throw new Error(`${key} is not an input property of this class.`);
|
||||
this.props.descriptor.inputNames.map((inputName) => {
|
||||
let initialValue = initialValues.get(inputName);
|
||||
if (!initialValue) {
|
||||
initialValue = { value: undefined, hidden: false };
|
||||
}
|
||||
currentValues = currentValues.set(inputName, initialValue);
|
||||
baselineValues = baselineValues.set(inputName, initialValue);
|
||||
initialValues.delete(inputName);
|
||||
});
|
||||
|
||||
if (initialValues.size > 0) {
|
||||
const keys = [];
|
||||
for (const key of initialValues.keys()) {
|
||||
keys.push(key);
|
||||
}
|
||||
|
||||
currentValues = currentValues.set(key, initialValues.get(key));
|
||||
baselineValues = baselineValues.set(key, initialValues.get(key));
|
||||
this.setState({
|
||||
compileErrorMessage: `The following fields have default values set but are not input properties of this class: ${keys.join(
|
||||
", "
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
this.setState({ currentValues, baselineValues, isRefreshing: false });
|
||||
this.setState({ currentValues, baselineValues });
|
||||
};
|
||||
|
||||
public resetBaselineValues = (): void => {
|
||||
const currentValues = this.state.currentValues;
|
||||
let baselineValues = this.state.baselineValues;
|
||||
for (const key of currentValues.keys()) {
|
||||
const currentValue = currentValues.get(key);
|
||||
baselineValues = baselineValues.set(key, { ...currentValue });
|
||||
}
|
||||
this.setState({ baselineValues });
|
||||
};
|
||||
|
||||
public discard = (): void => {
|
||||
let { currentValues } = this.state;
|
||||
const { baselineValues } = this.state;
|
||||
for (const key of baselineValues.keys()) {
|
||||
currentValues = currentValues.set(key, baselineValues.get(key));
|
||||
for (const key of currentValues.keys()) {
|
||||
const baselineValue = baselineValues.get(key);
|
||||
currentValues = currentValues.set(key, { ...baselineValue });
|
||||
}
|
||||
this.setState({ currentValues });
|
||||
};
|
||||
|
||||
private initializeSmartUiNode = async (currentNode: Node): Promise<void> => {
|
||||
private initializeSmartUiNode = async (
|
||||
currentNode: Node,
|
||||
currentValues: Map<string, SmartUiInput>,
|
||||
baselineValues: Map<string, SmartUiInput>
|
||||
): Promise<void> => {
|
||||
currentNode.info = await this.getResolvedValue(currentNode.info);
|
||||
|
||||
if (currentNode.input) {
|
||||
currentNode.input = await this.getResolvedInput(currentNode.input);
|
||||
currentNode.input = await this.getResolvedInput(currentNode.input, currentValues, baselineValues);
|
||||
}
|
||||
|
||||
const promises = currentNode.children?.map(async (child: Node) => await this.initializeSmartUiNode(child));
|
||||
const promises = currentNode.children?.map(
|
||||
async (child: Node) => await this.initializeSmartUiNode(child, currentValues, baselineValues)
|
||||
);
|
||||
if (promises) {
|
||||
await Promise.all(promises);
|
||||
}
|
||||
};
|
||||
|
||||
private getResolvedInput = async (input: AnyInput): Promise<AnyInput> => {
|
||||
input.label = await this.getResolvedValue(input.label);
|
||||
input.placeholder = await this.getResolvedValue(input.placeholder);
|
||||
private getResolvedInput = async (
|
||||
input: AnyDisplay,
|
||||
currentValues: Map<string, SmartUiInput>,
|
||||
baselineValues: Map<string, SmartUiInput>
|
||||
): Promise<AnyDisplay> => {
|
||||
input.labelTKey = await this.getResolvedValue(input.labelTKey);
|
||||
input.placeholderTKey = await this.getResolvedValue(input.placeholderTKey);
|
||||
|
||||
switch (input.type) {
|
||||
case "string": {
|
||||
if ("description" in input) {
|
||||
const descriptionDisplay = input as DescriptionDisplay;
|
||||
descriptionDisplay.description = await this.getResolvedValue(descriptionDisplay.description);
|
||||
}
|
||||
return input as StringInput;
|
||||
}
|
||||
case "number": {
|
||||
@@ -143,12 +169,22 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
|
||||
numberInput.min = await this.getResolvedValue(numberInput.min);
|
||||
numberInput.max = await this.getResolvedValue(numberInput.max);
|
||||
numberInput.step = await this.getResolvedValue(numberInput.step);
|
||||
|
||||
const dataFieldName = numberInput.dataFieldName;
|
||||
const defaultValue = currentValues.get(dataFieldName)?.value;
|
||||
|
||||
if (!defaultValue) {
|
||||
const newDefaultValue = { value: numberInput.min, hidden: currentValues.get(dataFieldName)?.hidden };
|
||||
currentValues.set(dataFieldName, newDefaultValue);
|
||||
baselineValues.set(dataFieldName, newDefaultValue);
|
||||
}
|
||||
|
||||
return numberInput;
|
||||
}
|
||||
case "boolean": {
|
||||
const booleanInput = input as BooleanInput;
|
||||
booleanInput.trueLabel = await this.getResolvedValue(booleanInput.trueLabel);
|
||||
booleanInput.falseLabel = await this.getResolvedValue(booleanInput.falseLabel);
|
||||
booleanInput.trueLabelTKey = await this.getResolvedValue(booleanInput.trueLabelTKey);
|
||||
booleanInput.falseLabelTKey = await this.getResolvedValue(booleanInput.falseLabelTKey);
|
||||
return booleanInput;
|
||||
}
|
||||
default: {
|
||||
@@ -166,53 +202,180 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
|
||||
return value;
|
||||
}
|
||||
|
||||
private onInputChange = (input: AnyInput, newValue: InputType) => {
|
||||
private onInputChange = (input: AnyDisplay, newValue: InputType) => {
|
||||
if (input.onChange) {
|
||||
const newValues = input.onChange(this.state.currentValues, newValue);
|
||||
this.setState({ currentValues: newValues });
|
||||
} else {
|
||||
const dataFieldName = input.dataFieldName;
|
||||
const { currentValues } = this.state;
|
||||
currentValues.set(dataFieldName, newValue);
|
||||
const currentInputValue = currentValues.get(dataFieldName);
|
||||
currentValues.set(dataFieldName, { value: newValue, hidden: currentInputValue?.hidden });
|
||||
this.setState({ currentValues });
|
||||
}
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const containerStackTokens: IStackTokens = { childrenGap: 20 };
|
||||
return !this.state.isRefreshing ? (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
|
||||
<SmartUiComponent
|
||||
descriptor={this.state.root as SmartUiDescriptor}
|
||||
currentValues={this.state.currentValues}
|
||||
onInputChange={this.onInputChange}
|
||||
/>
|
||||
public onSaveButtonClick = (): void => {
|
||||
const onSavePromise = this.props.descriptor.onSave(this.state.currentValues);
|
||||
onSavePromise.catch((error) => {
|
||||
this.setState({
|
||||
notification: {
|
||||
message: `${error.message}`,
|
||||
type: SelfServeNotificationType.error,
|
||||
},
|
||||
});
|
||||
});
|
||||
onSavePromise.then((notification: SelfServeNotification) => {
|
||||
this.setState({
|
||||
notification: {
|
||||
message: notification.message,
|
||||
type: notification.type,
|
||||
},
|
||||
});
|
||||
this.resetBaselineValues();
|
||||
this.onRefreshClicked();
|
||||
});
|
||||
};
|
||||
|
||||
<Stack horizontal tokens={{ childrenGap: 10 }}>
|
||||
<PrimaryButton
|
||||
id="submitButton"
|
||||
styles={{ root: { width: 100 } }}
|
||||
text="submit"
|
||||
onClick={async () => {
|
||||
await this.props.descriptor.onSubmit(this.state.currentValues);
|
||||
this.setDefaults();
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
id="discardButton"
|
||||
styles={{ root: { width: 100 } }}
|
||||
text="discard"
|
||||
onClick={() => this.discard()}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
) : (
|
||||
<Spinner
|
||||
size={SpinnerSize.large}
|
||||
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
|
||||
/>
|
||||
public isDiscardButtonDisabled = (): boolean => {
|
||||
for (const key of this.state.currentValues.keys()) {
|
||||
const currentValue = JSON.stringify(this.state.currentValues.get(key));
|
||||
const baselineValue = JSON.stringify(this.state.baselineValues.get(key));
|
||||
|
||||
if (currentValue !== baselineValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public isSaveButtonDisabled = (): boolean => {
|
||||
if (this.state.hasErrors) {
|
||||
return true;
|
||||
}
|
||||
for (const key of this.state.currentValues.keys()) {
|
||||
const currentValue = JSON.stringify(this.state.currentValues.get(key));
|
||||
const baselineValue = JSON.stringify(this.state.baselineValues.get(key));
|
||||
|
||||
if (currentValue !== baselineValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private performRefresh = async (): Promise<RefreshResult> => {
|
||||
const refreshResult = await this.props.descriptor.onRefresh();
|
||||
this.setState({ refreshResult: { ...refreshResult } });
|
||||
return refreshResult;
|
||||
};
|
||||
|
||||
public onRefreshClicked = async (): Promise<void> => {
|
||||
this.setState({ isInitializing: true });
|
||||
const refreshResult = await this.performRefresh();
|
||||
if (!refreshResult.isUpdateInProgress) {
|
||||
this.initializeSmartUiComponent();
|
||||
}
|
||||
this.setState({ isInitializing: false });
|
||||
};
|
||||
|
||||
public getCommonTranslation = (translationFunction: TFunction, key: string): string => {
|
||||
return translationFunction(`Common.${key}`);
|
||||
};
|
||||
|
||||
private getCommandBarItems = (translate: TFunction): ICommandBarItemProps[] => {
|
||||
return [
|
||||
{
|
||||
key: "save",
|
||||
text: this.getCommonTranslation(translate, "Save"),
|
||||
iconProps: { iconName: "Save" },
|
||||
split: true,
|
||||
disabled: this.isSaveButtonDisabled(),
|
||||
onClick: this.onSaveButtonClick,
|
||||
},
|
||||
{
|
||||
key: "discard",
|
||||
text: this.getCommonTranslation(translate, "Discard"),
|
||||
iconProps: { iconName: "Undo" },
|
||||
split: true,
|
||||
disabled: this.isDiscardButtonDisabled(),
|
||||
onClick: () => {
|
||||
this.discard();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
text: this.getCommonTranslation(translate, "Refresh"),
|
||||
disabled: this.state.isInitializing,
|
||||
iconProps: { iconName: "Refresh" },
|
||||
split: true,
|
||||
onClick: () => {
|
||||
this.onRefreshClicked();
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
private getNotificationMessageTranslation = (translationFunction: TFunction, messageKey: string): string => {
|
||||
const translation = translationFunction(messageKey);
|
||||
if (translation === `${this.smartUiGeneratorClassName}.${messageKey}`) {
|
||||
return messageKey;
|
||||
}
|
||||
return translation;
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const containerStackTokens: IStackTokens = { childrenGap: 5 };
|
||||
if (this.state.compileErrorMessage) {
|
||||
return <MessageBar messageBarType={MessageBarType.error}>{this.state.compileErrorMessage}</MessageBar>;
|
||||
}
|
||||
return (
|
||||
<Translation>
|
||||
{(translate) => {
|
||||
const getTranslation = (key: string): string => {
|
||||
return translate(`${this.smartUiGeneratorClassName}.${key}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<Stack tokens={containerStackTokens} styles={{ root: { padding: 10 } }}>
|
||||
<CommandBar styles={{ root: { paddingLeft: 0 } }} items={this.getCommandBarItems(translate)} />
|
||||
{this.state.isInitializing ? (
|
||||
<Spinner
|
||||
size={SpinnerSize.large}
|
||||
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{this.state.refreshResult?.isUpdateInProgress && (
|
||||
<MessageBar messageBarType={MessageBarType.info} styles={{ root: { width: 400 } }}>
|
||||
{getTranslation(this.state.refreshResult.notificationMessage)}
|
||||
</MessageBar>
|
||||
)}
|
||||
{this.state.notification && (
|
||||
<MessageBar
|
||||
messageBarType={getMessageBarType(this.state.notification.type)}
|
||||
styles={{ root: { width: 400 } }}
|
||||
onDismiss={() => this.setState({ notification: undefined })}
|
||||
>
|
||||
{this.getNotificationMessageTranslation(getTranslation, this.state.notification.message)}
|
||||
</MessageBar>
|
||||
)}
|
||||
<SmartUiComponent
|
||||
disabled={this.state.refreshResult?.isUpdateInProgress}
|
||||
descriptor={this.state.root as SmartUiDescriptor}
|
||||
currentValues={this.state.currentValues}
|
||||
onInputChange={this.onInputChange}
|
||||
onError={this.onError}
|
||||
getTranslation={getTranslation}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Translation>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import * as ko from "knockout";
|
||||
import * as React from "react";
|
||||
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
|
||||
import Explorer from "../Explorer/Explorer";
|
||||
import { SelfServeDescriptor, SelfServeComponent } from "./SelfServeComponent";
|
||||
import { SelfServeComponent } from "./SelfServeComponent";
|
||||
import { SelfServeDescriptor } from "./SelfServeTypes";
|
||||
import { SelfServeType } from "./SelfServeUtils";
|
||||
|
||||
export class SelfServeComponentAdapter implements ReactAdapter {
|
||||
@@ -28,6 +29,10 @@ export class SelfServeComponentAdapter implements ReactAdapter {
|
||||
const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample");
|
||||
return new SelfServeExample.default().toSelfServeDescriptor();
|
||||
}
|
||||
case SelfServeType.sqlx: {
|
||||
const SqlX = await import(/* webpackChunkName: "SqlX" */ "./SqlX/SqlX");
|
||||
return new SqlX.default().toSelfServeDescriptor();
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
130
src/SelfServe/SelfServeTypes.ts
Normal file
130
src/SelfServe/SelfServeTypes.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
interface BaseInput {
|
||||
dataFieldName: string;
|
||||
errorMessage?: string;
|
||||
type: InputTypeValue;
|
||||
labelTKey?: (() => Promise<string>) | string;
|
||||
onChange?: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>;
|
||||
placeholderTKey?: (() => Promise<string>) | string;
|
||||
}
|
||||
|
||||
export interface NumberInput extends BaseInput {
|
||||
min: (() => Promise<number>) | number;
|
||||
max: (() => Promise<number>) | number;
|
||||
step: (() => Promise<number>) | number;
|
||||
defaultValue?: number;
|
||||
uiType: NumberUiType;
|
||||
}
|
||||
|
||||
export interface BooleanInput extends BaseInput {
|
||||
trueLabelTKey: (() => Promise<string>) | string;
|
||||
falseLabelTKey: (() => Promise<string>) | string;
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
|
||||
export interface StringInput extends BaseInput {
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface ChoiceInput extends BaseInput {
|
||||
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
|
||||
defaultKey?: string;
|
||||
}
|
||||
|
||||
export interface DescriptionDisplay extends BaseInput {
|
||||
description: (() => Promise<Description>) | Description;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
info?: (() => Promise<Info>) | Info;
|
||||
input?: AnyDisplay;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
export interface SelfServeDescriptor {
|
||||
root: Node;
|
||||
initialize?: () => Promise<Map<string, SmartUiInput>>;
|
||||
onSave?: (currentValues: Map<string, SmartUiInput>) => Promise<SelfServeNotification>;
|
||||
inputNames?: string[];
|
||||
onRefresh?: () => Promise<RefreshResult>;
|
||||
}
|
||||
|
||||
export type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay;
|
||||
|
||||
export abstract class SelfServeBaseClass {
|
||||
public abstract initialize: () => Promise<Map<string, SmartUiInput>>;
|
||||
public abstract onSave: (currentValues: Map<string, SmartUiInput>) => Promise<SelfServeNotification>;
|
||||
public abstract onRefresh: () => Promise<RefreshResult>;
|
||||
|
||||
public toSelfServeDescriptor(): SelfServeDescriptor {
|
||||
const className = this.constructor.name;
|
||||
const selfServeDescriptor = Reflect.getMetadata(className, this) as SelfServeDescriptor;
|
||||
|
||||
if (!this.initialize) {
|
||||
throw new Error(`initialize() was not declared for the class '${className}'`);
|
||||
}
|
||||
if (!this.onSave) {
|
||||
throw new Error(`onSave() was not declared for the class '${className}'`);
|
||||
}
|
||||
if (!this.onRefresh) {
|
||||
throw new Error(`onRefresh() was not declared for the class '${className}'`);
|
||||
}
|
||||
if (!selfServeDescriptor?.root) {
|
||||
throw new Error(`@SmartUi decorator was not declared for the class '${className}'`);
|
||||
}
|
||||
|
||||
selfServeDescriptor.initialize = this.initialize;
|
||||
selfServeDescriptor.onSave = this.onSave;
|
||||
selfServeDescriptor.onRefresh = this.onRefresh;
|
||||
return selfServeDescriptor;
|
||||
}
|
||||
}
|
||||
|
||||
export type InputTypeValue = "number" | "string" | "boolean" | "object";
|
||||
|
||||
export enum NumberUiType {
|
||||
Spinner = "Spinner",
|
||||
Slider = "Slider",
|
||||
}
|
||||
|
||||
export type ChoiceItem = { label: string; key: string };
|
||||
|
||||
export type InputType = number | string | boolean | ChoiceItem;
|
||||
|
||||
export interface Info {
|
||||
messageTKey: string;
|
||||
link?: {
|
||||
href: string;
|
||||
textTKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Description {
|
||||
textTKey: string;
|
||||
link?: {
|
||||
href: string;
|
||||
textTKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SmartUiInput {
|
||||
value: InputType;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export enum SelfServeNotificationType {
|
||||
info = "info",
|
||||
warning = "warning",
|
||||
error = "error",
|
||||
}
|
||||
|
||||
export interface SelfServeNotification {
|
||||
message: string;
|
||||
type: SelfServeNotificationType;
|
||||
}
|
||||
|
||||
export interface RefreshResult {
|
||||
isUpdateInProgress: boolean;
|
||||
notificationMessage: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user