From 08ee86ecf165bf93c0433d8932345a5ed0a4a64b Mon Sep 17 00:00:00 2001 From: Vignesh Rangaishenvi <5912182+vignesh-mr@users.noreply.github.com> Date: Thu, 6 Aug 2020 16:15:31 -0700 Subject: [PATCH 01/13] Fix connection string renew token pane (#136) * Fix IcM issue + conn string parsing * format code * Undo fix for IcM issue --- src/Common/Constants.ts | 5 ++++- src/Platform/Hosted/Helpers/ConnectionStringParser.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index b093001d7..5919971d5 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -14,7 +14,10 @@ export class BackendEndpoints { } export class EndpointsRegex { - public static readonly cassandra = "AccountEndpoint=(.*).cassandra.cosmosdb.azure.com"; + public static readonly cassandra = [ + "AccountEndpoint=(.*).cassandra.cosmosdb.azure.com", + "HostName=(.*).cassandra.cosmos.azure.com" + ]; public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com"; public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com"; public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com"; diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts index 9faa2a22b..423a85f09 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts @@ -20,9 +20,13 @@ export class ConnectionStringParser { const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute); accessInput.accountName = matches && matches.length > 1 && matches[2]; accessInput.apiKind = DataModels.ApiKind.MongoDBCompute; - } else if (RegExp(Constants.EndpointsRegex.cassandra).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.cassandra)[1]; - accessInput.apiKind = DataModels.ApiKind.Cassandra; + } else if (Constants.EndpointsRegex.cassandra.some(regex => RegExp(regex).test(connectionStringPart))) { + Constants.EndpointsRegex.cassandra.forEach(regex => { + if (RegExp(regex).test(connectionStringPart)) { + accessInput.accountName = connectionStringPart.match(regex)[1]; + accessInput.apiKind = DataModels.ApiKind.Cassandra; + } + }); } else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) { accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1]; accessInput.apiKind = DataModels.ApiKind.Table; From 455a6ac81ba95d19c011200d464671b6b9eccd0f Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Thu, 6 Aug 2020 18:15:40 -0500 Subject: [PATCH 02/13] Fix update offer beyond throughput limit error (#135) --- src/Common/DataAccessUtilityBase.ts | 24 --------- src/Common/DocumentClientUtilityBase.ts | 34 ------------ .../updateOfferThroughputBeyondLimit.test.ts | 26 ++++++++++ .../updateOfferThroughputBeyondLimit.ts | 52 +++++++++++++++++++ src/Contracts/DataModels.ts | 11 ---- src/Explorer/Tabs/DatabaseSettingsTab.ts | 10 ++-- src/Explorer/Tabs/SettingsTab.ts | 13 ++--- 7 files changed, 88 insertions(+), 82 deletions(-) create mode 100644 src/Common/dataAccess/updateOfferThroughputBeyondLimit.test.ts create mode 100644 src/Common/dataAccess/updateOfferThroughputBeyondLimit.ts diff --git a/src/Common/DataAccessUtilityBase.ts b/src/Common/DataAccessUtilityBase.ts index 44dfd69ee..4ed13f883 100644 --- a/src/Common/DataAccessUtilityBase.ts +++ b/src/Common/DataAccessUtilityBase.ts @@ -26,7 +26,6 @@ import { OfferUtils } from "../Utils/OfferUtils"; import { RequestOptions } from "@azure/cosmos/dist-esm"; import StoredProcedure from "../Explorer/Tree/StoredProcedure"; import { Platform, configContext } from "../ConfigContext"; -import { getAuthorizationHeader } from "../Utils/AuthorizationUtils"; import DocumentId from "../Explorer/Tree/DocumentId"; import ConflictId from "../Explorer/Tree/ConflictId"; @@ -640,29 +639,6 @@ export function queryConflicts( return Q(documentsIterator); } -export async function updateOfferThroughputBeyondLimit( - request: DataModels.UpdateOfferThroughputRequest -): Promise { - if (configContext.platform !== Platform.Portal) { - throw new Error("Updating throughput beyond specified limit is not supported on this platform"); - } - - const explorer = window.dataExplorer; - const url = `${explorer.extensionEndpoint()}/api/offerthroughputrequest/updatebeyondspecifiedlimit`; - const authorizationHeader = getAuthorizationHeader(); - - const response = await fetch(url, { - method: "POST", - body: JSON.stringify(request), - headers: { [authorizationHeader.header]: authorizationHeader.token } - }); - - if (response.ok) { - return undefined; - } - throw new Error(await response.text()); -} - function _createDatabase(request: DataModels.CreateDatabaseRequest, options: any = {}): Q.Promise { const { databaseId, databaseLevelThroughput, offerThroughput, autoPilot, hasAutoPilotV2FeatureFlag } = request; const createBody: DatabaseRequest = { id: databaseId }; diff --git a/src/Common/DocumentClientUtilityBase.ts b/src/Common/DocumentClientUtilityBase.ts index 57742dc67..05a6ff017 100644 --- a/src/Common/DocumentClientUtilityBase.ts +++ b/src/Common/DocumentClientUtilityBase.ts @@ -383,40 +383,6 @@ export function updateOffer( return deferred.promise; } -export function updateOfferThroughputBeyondLimit( - requestPayload: DataModels.UpdateOfferThroughputRequest -): Q.Promise { - const deferred: Q.Deferred = Q.defer(); - const resourceDescriptionInfo: string = requestPayload.collectionName - ? `database ${requestPayload.databaseName} and container ${requestPayload.collectionName}` - : `database ${requestPayload.databaseName}`; - const id = NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.InProgress, - `Requesting increase in throughput to ${requestPayload.throughput} for ${resourceDescriptionInfo}` - ); - DataAccessUtilityBase.updateOfferThroughputBeyondLimit(requestPayload) - .then( - () => { - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Info, - `Successfully requested an increase in throughput to ${requestPayload.throughput} for ${resourceDescriptionInfo}` - ); - deferred.resolve(); - }, - (error: any) => { - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Failed to request an increase in throughput for ${requestPayload.throughput}: ${JSON.stringify(error)}` - ); - sendNotificationForError(error); - deferred.reject(error); - } - ) - .finally(() => NotificationConsoleUtils.clearInProgressMessageWithId(id)); - - return deferred.promise.timeout(Constants.ClientDefaults.requestTimeoutMs); -} - export function updateStoredProcedure( collection: ViewModels.Collection, storedProcedure: DataModels.StoredProcedure, diff --git a/src/Common/dataAccess/updateOfferThroughputBeyondLimit.test.ts b/src/Common/dataAccess/updateOfferThroughputBeyondLimit.test.ts new file mode 100644 index 000000000..93d6add04 --- /dev/null +++ b/src/Common/dataAccess/updateOfferThroughputBeyondLimit.test.ts @@ -0,0 +1,26 @@ +import { updateOfferThroughputBeyondLimit } from "./updateOfferThroughputBeyondLimit"; + +describe("updateOfferThroughputBeyondLimit", () => { + it("should call fetch", async () => { + window.fetch = jest.fn(() => { + return { + ok: true + }; + }); + window.dataExplorer = { + logConsoleData: jest.fn(), + deleteInProgressConsoleDataWithId: jest.fn(), + extensionEndpoint: jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + await updateOfferThroughputBeyondLimit({ + subscriptionId: "foo", + resourceGroup: "foo", + databaseAccountName: "foo", + databaseName: "foo", + throughput: 1000000000, + offerIsRUPerMinuteThroughputEnabled: false + }); + expect(window.fetch).toHaveBeenCalled(); + }); +}); diff --git a/src/Common/dataAccess/updateOfferThroughputBeyondLimit.ts b/src/Common/dataAccess/updateOfferThroughputBeyondLimit.ts new file mode 100644 index 000000000..43fcc849f --- /dev/null +++ b/src/Common/dataAccess/updateOfferThroughputBeyondLimit.ts @@ -0,0 +1,52 @@ +import { Platform, configContext } from "../../ConfigContext"; +import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; +import { AutoPilotOfferSettings } from "../../Contracts/DataModels"; +import { logConsoleProgress, logConsoleInfo, logConsoleError } from "../../Utils/NotificationConsoleUtils"; +import { HttpHeaders } from "../Constants"; + +interface UpdateOfferThroughputRequest { + subscriptionId: string; + resourceGroup: string; + databaseAccountName: string; + databaseName: string; + collectionName?: string; + throughput: number; + offerIsRUPerMinuteThroughputEnabled: boolean; + offerAutopilotSettings?: AutoPilotOfferSettings; +} + +export async function updateOfferThroughputBeyondLimit(request: UpdateOfferThroughputRequest): Promise { + if (configContext.platform !== Platform.Portal) { + throw new Error("Updating throughput beyond specified limit is not supported on this platform"); + } + + const resourceDescriptionInfo = request.collectionName + ? `database ${request.databaseName} and container ${request.collectionName}` + : `database ${request.databaseName}`; + + const clearMessage = logConsoleProgress( + `Requesting increase in throughput to ${request.throughput} for ${resourceDescriptionInfo}` + ); + + const explorer = window.dataExplorer; + const url = `${explorer.extensionEndpoint()}/api/offerthroughputrequest/updatebeyondspecifiedlimit`; + const authorizationHeader = getAuthorizationHeader(); + + const response = await fetch(url, { + method: "POST", + body: JSON.stringify(request), + headers: { [authorizationHeader.header]: authorizationHeader.token, [HttpHeaders.contentType]: "application/json" } + }); + + if (response.ok) { + logConsoleInfo( + `Successfully requested an increase in throughput to ${request.throughput} for ${resourceDescriptionInfo}` + ); + clearMessage(); + return undefined; + } + const error = await response.json(); + logConsoleError(`Failed to request an increase in throughput for ${request.throughput}: ${error.message}`); + clearMessage(); + throw new Error(error.message); +} diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 85b767204..fa8a77a69 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -312,17 +312,6 @@ export interface Query { query: string; } -export interface UpdateOfferThroughputRequest { - subscriptionId: string; - resourceGroup: string; - databaseAccountName: string; - databaseName: string; - collectionName: string; - throughput: number; - offerIsRUPerMinuteThroughputEnabled: boolean; - offerAutopilotSettings?: AutoPilotOfferSettings; -} - export interface AutoPilotOfferSettings { tier?: AutopilotTier; maximumTierThroughput?: number; diff --git a/src/Explorer/Tabs/DatabaseSettingsTab.ts b/src/Explorer/Tabs/DatabaseSettingsTab.ts index 053857bb8..75915b25b 100644 --- a/src/Explorer/Tabs/DatabaseSettingsTab.ts +++ b/src/Explorer/Tabs/DatabaseSettingsTab.ts @@ -16,9 +16,10 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants"; import { PlatformType } from "../../PlatformType"; import { RequestOptions } from "@azure/cosmos/dist-esm"; import Explorer from "../Explorer"; -import { updateOfferThroughputBeyondLimit, updateOffer } from "../../Common/DocumentClientUtilityBase"; +import { updateOffer } from "../../Common/DocumentClientUtilityBase"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { userContext } from "../../UserContext"; +import { updateOfferThroughputBeyondLimit } from "../../Common/dataAccess/updateOfferThroughputBeyondLimit"; const updateThroughputBeyondLimitWarningMessage: string = ` You are about to request an increase in throughput beyond the pre-allocated capacity. @@ -519,16 +520,15 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels. this.maxRUs() <= SharedConstants.CollectionCreation.DefaultCollectionRUs1Million && this.throughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million ) { - const requestPayload: DataModels.UpdateOfferThroughputRequest = { + const requestPayload = { subscriptionId: userContext.subscriptionId, databaseAccountName: userContext.databaseAccount.name, resourceGroup: userContext.resourceGroup, databaseName: this.database.id(), - collectionName: undefined, throughput: newThroughput, offerIsRUPerMinuteThroughputEnabled: false }; - const updateOfferBeyondLimitPromise: Q.Promise = updateOfferThroughputBeyondLimit(requestPayload).then( + const updateOfferBeyondLimitPromise = updateOfferThroughputBeyondLimit(requestPayload).then( () => { this.database.offer().content.offerThroughput = originalThroughputValue; this.throughput(originalThroughputValue); @@ -552,7 +552,7 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels. ); } ); - promises.push(updateOfferBeyondLimitPromise); + promises.push(Q(updateOfferBeyondLimitPromise)); } else { const newOffer: DataModels.Offer = { content: { diff --git a/src/Explorer/Tabs/SettingsTab.ts b/src/Explorer/Tabs/SettingsTab.ts index b7fd486a8..3a6d2de10 100644 --- a/src/Explorer/Tabs/SettingsTab.ts +++ b/src/Explorer/Tabs/SettingsTab.ts @@ -17,13 +17,10 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants"; import { PlatformType } from "../../PlatformType"; import { RequestOptions } from "@azure/cosmos/dist-esm"; import Explorer from "../Explorer"; -import { - updateOfferThroughputBeyondLimit, - updateOffer, - updateCollection -} from "../../Common/DocumentClientUtilityBase"; +import { updateOffer, updateCollection } from "../../Common/DocumentClientUtilityBase"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { userContext } from "../../UserContext"; +import { updateOfferThroughputBeyondLimit } from "../../Common/dataAccess/updateOfferThroughputBeyondLimit"; const ttlWarning: string = ` The system will automatically delete items based on the TTL value (in seconds) you provide, without needing a delete operation explicitly issued by a client application. @@ -1144,7 +1141,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor newThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million && this.container != null ) { - const requestPayload: DataModels.UpdateOfferThroughputRequest = { + const requestPayload = { subscriptionId: userContext.subscriptionId, databaseAccountName: userContext.databaseAccount.name, resourceGroup: userContext.resourceGroup, @@ -1153,7 +1150,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor throughput: newThroughput, offerIsRUPerMinuteThroughputEnabled: isRUPerMinuteThroughputEnabled }; - const updateOfferBeyondLimitPromise: Q.Promise = updateOfferThroughputBeyondLimit(requestPayload).then( + const updateOfferBeyondLimitPromise = updateOfferThroughputBeyondLimit(requestPayload).then( () => { this.collection.offer().content.offerThroughput = originalThroughputValue; this.throughput(originalThroughputValue); @@ -1185,7 +1182,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor ); } ); - promises.push(updateOfferBeyondLimitPromise); + promises.push(Q(updateOfferBeyondLimitPromise)); } else { const updateOfferPromise = updateOffer(this.collection.offer(), newOffer, headerOptions).then( (updatedOffer: DataModels.Offer) => { From 95f1efc03f744f49eff49417df704249f20b05a9 Mon Sep 17 00:00:00 2001 From: Srinath Narayanan Date: Mon, 10 Aug 2020 01:53:51 -0700 Subject: [PATCH 03/13] Added support for notebook viewer link injection (#124) * Added support for notebook viewer link injection * updated tests --- src/Common/Constants.ts | 1 + .../FeaturePanel/FeaturePanelComponent.tsx | 5 +++ .../FeaturePanelComponent.test.tsx.snap | 10 ++++-- .../Cards/GalleryCardComponent.test.tsx | 3 +- .../NotebookMetadataComponent.test.tsx | 6 ++-- .../NotebookViewerComponent.tsx | 15 ++++++++- src/Explorer/Explorer.ts | 6 +++- src/Explorer/Notebook/NotebookManager.ts | 5 +-- .../Panes/PublishNotebookPaneAdapter.tsx | 10 ++++-- .../Panes/PublishNotebookPaneComponent.tsx | 3 +- ...PublishNotebookPaneComponent.test.tsx.snap | 1 + src/Juno/JunoClient.test.ts | 7 ++-- src/Juno/JunoClient.ts | 33 +++++++++++++------ src/NotebookViewer/NotebookViewer.tsx | 26 +++++++++++---- src/Utils/GalleryUtils.test.ts | 3 +- 15 files changed, 101 insertions(+), 33 deletions(-) diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 5919971d5..44d106669 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -116,6 +116,7 @@ export class Features { public static readonly enableTtl = "enablettl"; public static readonly enableNotebooks = "enablenotebooks"; public static readonly enableGalleryPublish = "enablegallerypublish"; + public static readonly enableLinkInjection = "enablelinkinjection"; public static readonly enableSpark = "enablespark"; public static readonly livyEndpoint = "livyendpoint"; public static readonly notebookServerUrl = "notebookserverurl"; diff --git a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx index c93e542dc..bbbb3d96d 100644 --- a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx +++ b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx @@ -49,6 +49,11 @@ export const FeaturePanelComponent: React.FunctionComponent = () => { { key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" }, { key: "feature.enablettl", label: "Enable TTL", value: "true" }, { key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" }, + { + key: "feature.enableLinkInjection", + label: "Enable Injecting Notebook Viewer Link into the first cell", + value: "true" + }, { key: "feature.canexceedmaximumvalue", label: "Can exceed max value", value: "true" }, { key: "feature.enablefixedcollectionwithsharedthroughput", diff --git a/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap b/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap index c801e9649..885ace128 100644 --- a/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap +++ b/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap @@ -163,8 +163,8 @@ exports[`Feature panel renders all flags 1`] = ` /> @@ -172,6 +172,12 @@ exports[`Feature panel renders all flags 1`] = ` className="checkboxRow" horizontalAlign="space-between" > + { isSample: false, downloads: 0, favorites: 0, - views: 0 + views: 0, + newCellId: undefined }, isFavorite: false, showDownload: true, diff --git a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx index 1fb97e48e..e81f2cf42 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx @@ -17,7 +17,8 @@ describe("NotebookMetadataComponent", () => { isSample: false, downloads: 0, favorites: 0, - views: 0 + views: 0, + newCellId: undefined }, isFavorite: false, downloadButtonText: "Download", @@ -45,7 +46,8 @@ describe("NotebookMetadataComponent", () => { isSample: false, downloads: 0, favorites: 0, - views: 0 + views: 0, + newCellId: undefined }, isFavorite: true, downloadButtonText: "Download", diff --git a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx index e1c461961..d32d4186b 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx @@ -19,6 +19,7 @@ import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComp import { NotebookMetadataComponent } from "./NotebookMetadataComponent"; import "./NotebookViewerComponent.less"; import Explorer from "../../Explorer"; +import { NotebookV4 } from "@nteract/commutable/lib/v4"; import { SessionStorageUtility } from "../../../Shared/StorageUtility"; export interface NotebookViewerComponentProps { @@ -86,6 +87,7 @@ export class NotebookViewerComponent extends React.Component< } const notebook: Notebook = await response.json(); + this.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId); this.notebookComponentBootstrapper.setContent("json", notebook); this.setState({ content: notebook, showProgressBar: false }); @@ -105,10 +107,21 @@ export class NotebookViewerComponent extends React.Component< } } + private removeNotebookViewerLink = (notebook: Notebook, newCellId: string): void => { + if (!newCellId) { + return; + } + const notebookV4 = notebook as NotebookV4; + if (notebookV4 && notebookV4.cells[0].source[0].search(newCellId)) { + delete notebookV4.cells[0]; + notebook = notebookV4; + } + }; + public render(): JSX.Element { return (
- {this.props.backNavigationText ? ( + {this.props.backNavigationText !== undefined ? ( {this.props.backNavigationText} diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 684ded5d8..0d6fb54b9 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -206,6 +206,7 @@ export default class Explorer { // features public isGalleryPublishEnabled: ko.Computed; + public isLinkInjectionEnabled: ko.Computed; public isGitHubPaneEnabled: ko.Observable; public isPublishNotebookPaneEnabled: ko.Observable; public isHostedDataExplorerEnabled: ko.Computed; @@ -408,6 +409,9 @@ export default class Explorer { this.isGalleryPublishEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableGalleryPublish) ); + this.isLinkInjectionEnabled = ko.computed(() => + this.isFeatureEnabled(Constants.Features.enableLinkInjection) + ); this.isGitHubPaneEnabled = ko.observable(false); this.isPublishNotebookPaneEnabled = ko.observable(false); @@ -2349,7 +2353,7 @@ export default class Explorer { public publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): void { if (this.notebookManager) { - this.notebookManager.openPublishNotebookPane(name, content, parentDomElement); + this.notebookManager.openPublishNotebookPane(name, content, parentDomElement, this.isLinkInjectionEnabled()); this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; this.isPublishNotebookPaneEnabled(true); } diff --git a/src/Explorer/Notebook/NotebookManager.ts b/src/Explorer/Notebook/NotebookManager.ts index eeafd2cbf..b1d606985 100644 --- a/src/Explorer/Notebook/NotebookManager.ts +++ b/src/Explorer/Notebook/NotebookManager.ts @@ -111,9 +111,10 @@ export default class NotebookManager { public openPublishNotebookPane( name: string, content: string | ImmutableNotebook, - parentDomElement: HTMLElement + parentDomElement: HTMLElement, + isLinkInjectionEnabled: boolean ): void { - this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement); + this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement, isLinkInjectionEnabled); } // Octokit's error handler uses any diff --git a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx index c6ef6a7b5..0796bb052 100644 --- a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx @@ -26,6 +26,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { private imageSrc: string; private notebookObject: ImmutableNotebook; private parentDomElement: HTMLElement; + private isLinkInjectionEnabled: boolean; constructor(private container: Explorer, private junoClient: JunoClient) { this.parameters = ko.observable(Date.now()); @@ -62,19 +63,21 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { name: string, author: string, notebookContent: string | ImmutableNotebook, - parentDomElement: HTMLElement + parentDomElement: HTMLElement, + isLinkInjectionEnabled: boolean ): void { this.name = name; this.author = author; if (typeof notebookContent === "string") { this.content = notebookContent as string; } else { - this.content = JSON.stringify(toJS(notebookContent as ImmutableNotebook)); + this.content = JSON.stringify(toJS(notebookContent)); this.notebookObject = notebookContent; } this.parentDomElement = parentDomElement; this.isOpened = true; + this.isLinkInjectionEnabled = isLinkInjectionEnabled; this.triggerRender(); } @@ -102,7 +105,8 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { this.tags?.split(","), this.author, this.imageSrc, - this.content + this.content, + this.isLinkInjectionEnabled ); if (!response.data) { throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`); diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx index cbb248de7..29aab162c 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx @@ -276,7 +276,8 @@ export class PublishNotebookPaneComponent extends React.Component { json: () => undefined as any }); - const response = await junoClient.getNotebook(id); + const response = await junoClient.getNotebookInfo(id); expect(response.status).toBe(HttpStatusCodes.OK); expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`); @@ -353,7 +354,7 @@ describe("Gallery", () => { json: () => undefined as any }); - const response = await junoClient.publishNotebook(name, description, tags, author, thumbnailUrl, content); + const response = await junoClient.publishNotebook(name, description, tags, author, thumbnailUrl, content, false); const authorizationHeader = getAuthorizationHeader(); expect(response.status).toBe(HttpStatusCodes.OK); diff --git a/src/Juno/JunoClient.ts b/src/Juno/JunoClient.ts index 032ddf8f3..1f5677de6 100644 --- a/src/Juno/JunoClient.ts +++ b/src/Juno/JunoClient.ts @@ -36,6 +36,7 @@ export interface IGalleryItem { downloads: number; favorites: number; views: number; + newCellId: string; } export interface IUserGallery { @@ -162,7 +163,7 @@ export class JunoClient { return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`); } - public async getNotebook(id: string): Promise> { + public async getNotebookInfo(id: string): Promise> { const response = await window.fetch(this.getNotebookInfoUrl(id)); let data: IGalleryItem; @@ -292,19 +293,31 @@ export class JunoClient { tags: string[], author: string, thumbnailUrl: string, - content: string + content: string, + isLinkInjectionEnabled: boolean ): Promise> { const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, { method: "PUT", headers: JunoClient.getHeaders(), - body: JSON.stringify({ - name, - description, - tags, - author, - thumbnailUrl, - content: JSON.parse(content) - } as IPublishNotebookRequest) + + body: isLinkInjectionEnabled + ? JSON.stringify({ + name, + description, + tags, + author, + thumbnailUrl, + content: JSON.parse(content), + addLinkToNotebookViewer: isLinkInjectionEnabled + } as IPublishNotebookRequest) + : JSON.stringify({ + name, + description, + tags, + author, + thumbnailUrl, + content: JSON.parse(content) + } as IPublishNotebookRequest) }); let data: IGalleryItem; diff --git a/src/NotebookViewer/NotebookViewer.tsx b/src/NotebookViewer/NotebookViewer.tsx index 31c38b62d..84d034b0e 100644 --- a/src/NotebookViewer/NotebookViewer.tsx +++ b/src/NotebookViewer/NotebookViewer.tsx @@ -11,34 +11,48 @@ import { IGalleryItem, JunoClient } from "../Juno/JunoClient"; import * as GalleryUtils from "../Utils/GalleryUtils"; import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent"; import { FileSystemUtil } from "../Explorer/Notebook/FileSystemUtil"; +import { config } from "../Config"; const onInit = async () => { initializeIcons(); await initializeConfiguration(); const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search); const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search); - const backNavigationText = galleryViewerProps.selectedTab && GalleryUtils.getTabTitle(galleryViewerProps.selectedTab); + let backNavigationText: string; + let onBackClick: () => void; + if (galleryViewerProps.selectedTab !== undefined) { + backNavigationText = GalleryUtils.getTabTitle(galleryViewerProps.selectedTab); + onBackClick = () => (window.location.href = `${config.hostedExplorerURL}gallery.html`); + } const hideInputs = notebookViewerProps.hideInputs; const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl); - render(notebookUrl, backNavigationText, hideInputs); const galleryItemId = notebookViewerProps.galleryItemId; + let galleryItem: IGalleryItem; + if (galleryItemId) { const junoClient = new JunoClient(); - const notebook = await junoClient.getNotebook(galleryItemId); - render(notebookUrl, backNavigationText, hideInputs, notebook.data); + const galleryItemJunoResponse = await junoClient.getNotebookInfo(galleryItemId); + galleryItem = galleryItemJunoResponse.data; } + render(notebookUrl, backNavigationText, hideInputs, galleryItem, onBackClick); }; -const render = (notebookUrl: string, backNavigationText: string, hideInputs: boolean, galleryItem?: IGalleryItem) => { +const render = ( + notebookUrl: string, + backNavigationText: string, + hideInputs: boolean, + galleryItem?: IGalleryItem, + onBackClick?: () => void +) => { const props: NotebookViewerComponentProps = { junoClient: galleryItem ? new JunoClient() : undefined, notebookUrl, galleryItem, backNavigationText, hideInputs, - onBackClick: undefined, + onBackClick: onBackClick, onTagClick: undefined }; diff --git a/src/Utils/GalleryUtils.test.ts b/src/Utils/GalleryUtils.test.ts index 1a95147e3..95365af40 100644 --- a/src/Utils/GalleryUtils.test.ts +++ b/src/Utils/GalleryUtils.test.ts @@ -16,7 +16,8 @@ const galleryItem: IGalleryItem = { isSample: false, downloads: 0, favorites: 0, - views: 0 + views: 0, + newCellId: undefined }; describe("GalleryUtils", () => { From e28765d74066f7ae64e1bfaeb8a3d53e935ead21 Mon Sep 17 00:00:00 2001 From: victor-meng <56978073+victor-meng@users.noreply.github.com> Date: Mon, 10 Aug 2020 09:55:43 -0700 Subject: [PATCH 04/13] Add null check when reading offerAutopilotSettings (#138) --- src/Explorer/Tabs/SettingsTab.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Explorer/Tabs/SettingsTab.ts b/src/Explorer/Tabs/SettingsTab.ts index 3a6d2de10..f3f24fd0a 100644 --- a/src/Explorer/Tabs/SettingsTab.ts +++ b/src/Explorer/Tabs/SettingsTab.ts @@ -344,7 +344,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor if (!this.isAutoPilotSelected()) { return false; } - const originalAutoPilotSettings = this.collection.offer().content.offerAutopilotSettings; + const originalAutoPilotSettings = this.collection?.offer()?.content?.offerAutopilotSettings; if (!originalAutoPilotSettings) { return false; } From a5b824ebb524aedc1ca3ee87bfe1ed6c1411f14b Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Mon, 10 Aug 2020 12:02:18 -0500 Subject: [PATCH 05/13] Fix master compile error --- src/NotebookViewer/NotebookViewer.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/NotebookViewer/NotebookViewer.tsx b/src/NotebookViewer/NotebookViewer.tsx index 84d034b0e..17484b893 100644 --- a/src/NotebookViewer/NotebookViewer.tsx +++ b/src/NotebookViewer/NotebookViewer.tsx @@ -2,7 +2,7 @@ import "bootstrap/dist/css/bootstrap.css"; import { initializeIcons } from "office-ui-fabric-react/lib/Icons"; import React from "react"; import * as ReactDOM from "react-dom"; -import { initializeConfiguration } from "../ConfigContext"; +import { initializeConfiguration, configContext } from "../ConfigContext"; import { NotebookViewerComponent, NotebookViewerComponentProps @@ -11,7 +11,6 @@ import { IGalleryItem, JunoClient } from "../Juno/JunoClient"; import * as GalleryUtils from "../Utils/GalleryUtils"; import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent"; import { FileSystemUtil } from "../Explorer/Notebook/FileSystemUtil"; -import { config } from "../Config"; const onInit = async () => { initializeIcons(); @@ -22,7 +21,7 @@ const onInit = async () => { let onBackClick: () => void; if (galleryViewerProps.selectedTab !== undefined) { backNavigationText = GalleryUtils.getTabTitle(galleryViewerProps.selectedTab); - onBackClick = () => (window.location.href = `${config.hostedExplorerURL}gallery.html`); + onBackClick = () => (window.location.href = `${configContext.hostedExplorerURL}gallery.html`); } const hideInputs = notebookViewerProps.hideInputs; From abce15a6b29de8dcb3d56187355425b6bc5b2d30 Mon Sep 17 00:00:00 2001 From: Vignesh Rangaishenvi <5912182+vignesh-mr@users.noreply.github.com> Date: Mon, 10 Aug 2020 15:02:24 -0700 Subject: [PATCH 06/13] Add init message when warming up notebook workspace (#139) * Add init message * Address comments --- src/Explorer/Explorer.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 0d6fb54b9..59fa442de 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -1723,6 +1723,7 @@ export default class Explorer { return; } + let clearMessage; try { const notebookWorkspace = await this.notebookWorkspaceManager.getNotebookWorkspaceAsync( this.databaseAccount().id, @@ -1734,10 +1735,14 @@ export default class Explorer { notebookWorkspace.properties.status && notebookWorkspace.properties.status.toLowerCase() === "stopped" ) { + clearMessage = NotificationConsoleUtils.logConsoleProgress("Initializing notebook workspace"); await this.notebookWorkspaceManager.startNotebookWorkspaceAsync(this.databaseAccount().id, "default"); } } catch (error) { Logger.logError(error, "Explorer/ensureNotebookWorkspaceRunning"); + NotificationConsoleUtils.logConsoleError(`Failed to initialize notebook workspace: ${JSON.stringify(error)}`); + } finally { + clearMessage && clearMessage(); } } From 305196109385654b4b8efb6b628841f3641c1cd9 Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Mon, 10 Aug 2020 18:43:45 -0500 Subject: [PATCH 07/13] Add subscriptionId and authType to telemetry (#140) --- src/Shared/Telemetry/TelemetryProcessor.ts | 4 + tsconfig.strict.json | 156 ++++++++++----------- 2 files changed, 82 insertions(+), 78 deletions(-) diff --git a/src/Shared/Telemetry/TelemetryProcessor.ts b/src/Shared/Telemetry/TelemetryProcessor.ts index d8a6f7f18..8d8d16d95 100644 --- a/src/Shared/Telemetry/TelemetryProcessor.ts +++ b/src/Shared/Telemetry/TelemetryProcessor.ts @@ -3,6 +3,7 @@ import { sendMessage } from "../../Common/MessageHandler"; import { MessageTypes } from "../../Contracts/ExplorerContracts"; import { appInsights } from "../appInsights"; import { configContext } from "../../ConfigContext"; +import { userContext } from "../../UserContext"; /** * Class that persists telemetry data to the portal tables. @@ -115,6 +116,9 @@ export default class TelemetryProcessor { private static getData(data?: any): any { return { + // TODO: Need to `any` here since the window imports Explorer which can't be in strict mode yet + authType: (window as any).authType, + subscriptionId: userContext.subscriptionId, platform: configContext.platform, ...(data ? data : []) }; diff --git a/tsconfig.strict.json b/tsconfig.strict.json index e5edef88f..055d811a2 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -1,79 +1,79 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "strictNullChecks": true, - "strict": true, - "noUnusedLocals": true - }, - "files": [ - "./src/AuthType.ts", - "./src/Bindings/BindingHandlersRegisterer.ts", - "./src/Bindings/ReactBindingHandler.ts", - "./src/Common/ArrayHashMap.ts", - "./src/Common/Constants.ts", - "./src/Common/DeleteFeedback.ts", - "./src/Common/HashMap.ts", - "./src/Common/HeadersUtility.ts", - "./src/Common/Logger.ts", - "./src/Common/MessageHandler.ts", - "./src/Common/MongoUtility.ts", - "./src/Common/ObjectCache.ts", - "./src/Common/ThemeUtility.ts", - "./src/Common/UrlUtility.ts", - "./src/Common/dataAccess/sendNotificationForError.ts", - "./src/ConfigContext.ts", - "./src/Contracts/ActionContracts.ts", - "./src/Contracts/DataModels.ts", - "./src/Contracts/Diagnostics.ts", - "./src/Contracts/ExplorerContracts.ts", - "./src/Contracts/Versions.ts", - "./src/Controls/Heatmap/Heatmap.ts", - "./src/Controls/Heatmap/HeatmapDatatypes.ts", - "./src/Definitions/globals.d.ts", - "./src/Definitions/html.d.ts", - "./src/Definitions/jquery-ui.d.ts", - "./src/Definitions/jquery.d.ts", - "./src/Definitions/plotly.js-cartesian-dist.d-min.ts", - "./src/Definitions/svg.d.ts", - "./src/Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.ts", - "./src/Explorer/Controls/GitHub/GitHubStyleConstants.ts", - "./src/Explorer/Controls/SmartUi/InputUtils.ts", - "./src/Explorer/Notebook/FileSystemUtil.ts", - "./src/Explorer/Notebook/NTeractUtil.ts", - "./src/Explorer/Notebook/NotebookComponent/actions.ts", - "./src/Explorer/Notebook/NotebookComponent/loadTransform.ts", - "./src/Explorer/Notebook/NotebookComponent/reducers.ts", - "./src/Explorer/Notebook/NotebookComponent/types.ts", - "./src/Explorer/Notebook/NotebookContentItem.ts", - "./src/Explorer/Notebook/NotebookUtil.ts", - "./src/Explorer/Panes/PaneComponents.ts", - "./src/Explorer/Panes/Tables/Validators/EntityPropertyNameValidator.ts", - "./src/Explorer/Panes/Tables/Validators/EntityPropertyValidationCommon.ts", - "./src/Explorer/Tables/Constants.ts", - "./src/Explorer/Tables/QueryBuilder/DateTimeUtilities.ts", - "./src/Explorer/Tabs/TabComponents.ts", - "./src/GitHub/GitHubConnector.ts", - "./src/Index.ts", - "./src/NotebookWorkspaceManager/NotebookWorkspaceResourceProviderMockClients.ts", - "./src/PlatformType.ts", - "./src/ReactDevTools.ts", - "./src/ResourceProvider/IResourceProviderClient.ts", - "./src/Shared/ExplorerSettings.ts", - "./src/Shared/StorageUtility.ts", - "./src/Shared/StringUtility.ts", - "./src/Shared/Telemetry/TelemetryConstants.ts", - "./src/Shared/Telemetry/TelemetryProcessor.ts", - "./src/Shared/appInsights.ts", - "./src/UserContext.ts", - "./src/Utils/GitHubUtils.ts", - "./src/Utils/MessageValidation.ts", - "./src/Utils/OfferUtils.ts", - "./src/Utils/StringUtils.ts", - "./src/Utils/arm/generatedClients/2020-04-01/types.ts", - "./src/quickstart.ts", - "./src/setupTests.ts", - "./src/workers/upload/definitions.ts" - ], - "include": [] -} \ No newline at end of file + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "strictNullChecks": true, + "strict": true, + "noUnusedLocals": true + }, + "files": [ + "./src/AuthType.ts", + "./src/Bindings/BindingHandlersRegisterer.ts", + "./src/Bindings/ReactBindingHandler.ts", + "./src/Common/ArrayHashMap.ts", + "./src/Common/Constants.ts", + "./src/Common/DeleteFeedback.ts", + "./src/Common/HashMap.ts", + "./src/Common/HeadersUtility.ts", + "./src/Common/Logger.ts", + "./src/Common/MessageHandler.ts", + "./src/Common/MongoUtility.ts", + "./src/Common/ObjectCache.ts", + "./src/Common/ThemeUtility.ts", + "./src/Common/UrlUtility.ts", + "./src/Common/dataAccess/sendNotificationForError.ts", + "./src/ConfigContext.ts", + "./src/Contracts/ActionContracts.ts", + "./src/Contracts/DataModels.ts", + "./src/Contracts/Diagnostics.ts", + "./src/Contracts/ExplorerContracts.ts", + "./src/Contracts/Versions.ts", + "./src/Controls/Heatmap/Heatmap.ts", + "./src/Controls/Heatmap/HeatmapDatatypes.ts", + "./src/Definitions/globals.d.ts", + "./src/Definitions/html.d.ts", + "./src/Definitions/jquery-ui.d.ts", + "./src/Definitions/jquery.d.ts", + "./src/Definitions/plotly.js-cartesian-dist.d-min.ts", + "./src/Definitions/svg.d.ts", + "./src/Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.ts", + "./src/Explorer/Controls/GitHub/GitHubStyleConstants.ts", + "./src/Explorer/Controls/SmartUi/InputUtils.ts", + "./src/Explorer/Notebook/FileSystemUtil.ts", + "./src/Explorer/Notebook/NTeractUtil.ts", + "./src/Explorer/Notebook/NotebookComponent/actions.ts", + "./src/Explorer/Notebook/NotebookComponent/loadTransform.ts", + "./src/Explorer/Notebook/NotebookComponent/reducers.ts", + "./src/Explorer/Notebook/NotebookComponent/types.ts", + "./src/Explorer/Notebook/NotebookContentItem.ts", + "./src/Explorer/Notebook/NotebookUtil.ts", + "./src/Explorer/Panes/PaneComponents.ts", + "./src/Explorer/Panes/Tables/Validators/EntityPropertyNameValidator.ts", + "./src/Explorer/Panes/Tables/Validators/EntityPropertyValidationCommon.ts", + "./src/Explorer/Tables/Constants.ts", + "./src/Explorer/Tables/QueryBuilder/DateTimeUtilities.ts", + "./src/Explorer/Tabs/TabComponents.ts", + "./src/GitHub/GitHubConnector.ts", + "./src/Index.ts", + "./src/NotebookWorkspaceManager/NotebookWorkspaceResourceProviderMockClients.ts", + "./src/PlatformType.ts", + "./src/ReactDevTools.ts", + "./src/ResourceProvider/IResourceProviderClient.ts", + "./src/Shared/ExplorerSettings.ts", + "./src/Shared/StorageUtility.ts", + "./src/Shared/StringUtility.ts", + "./src/Shared/Telemetry/TelemetryConstants.ts", + "./src/Shared/Telemetry/TelemetryProcessor.ts", + "./src/Shared/appInsights.ts", + "./src/UserContext.ts", + "./src/Utils/GitHubUtils.ts", + "./src/Utils/MessageValidation.ts", + "./src/Utils/OfferUtils.ts", + "./src/Utils/StringUtils.ts", + "./src/Utils/arm/generatedClients/2020-04-01/types.ts", + "./src/quickstart.ts", + "./src/setupTests.ts", + "./src/workers/upload/definitions.ts" + ], + "include": [] +} From 7a3e54d43e0a6560a88262104f2f0b2a0d66afe3 Mon Sep 17 00:00:00 2001 From: Srinath Narayanan Date: Tue, 11 Aug 2020 00:37:05 -0700 Subject: [PATCH 08/13] Added support for acknowledging code of conduct for using public Notebook Gallery (#117) * minro code edits * Added support for acknowledging code of conduct - Added CodeOfConduct component that shows links and a checkbox that must be acknwledged before seeing the public galley - Added verbose message for notebook publish error (when another notebook with the same name exists in the gallery) - Added a feature flag for enabling code of conduct acknowledgement * Added Info Component * minor edit * fixed failign tests * publish tab displayed only when code of conduct accepted * added code of conduct fetch during publish * fixed bug * added test and addressed PR comments * changed line endings * added comment * addressed PR comments --- src/Common/Constants.ts | 7 + src/Common/ErrorParserUtility.ts | 138 +++++++++--------- .../FeaturePanel/FeaturePanelComponent.tsx | 1 + .../FeaturePanelComponent.test.tsx.snap | 6 + .../CodeOfConductComponent.test.tsx | 43 ++++++ .../CodeOfConductComponent.tsx | 112 ++++++++++++++ .../GalleryViewerComponent.tsx | 70 +++++++-- .../InfoComponent/InfoComponent.less | 26 ++++ .../InfoComponent/InfoComponent.test.tsx | 10 ++ .../InfoComponent/InfoComponent.tsx | 42 ++++++ .../__snapshots__/InfoComponent.test.tsx.snap | 34 +++++ .../CodeOfConductComponent.test.tsx.snap | 75 ++++++++++ src/Explorer/Explorer.ts | 14 +- src/Explorer/Notebook/NotebookManager.ts | 14 +- src/Explorer/Notebook/NotebookUtil.test.ts | 6 +- src/Explorer/Notebook/NotebookUtil.ts | 24 ++- .../Panes/GenericRightPaneComponent.tsx | 2 + .../Panes/PublishNotebookPaneAdapter.tsx | 50 ++++++- src/Explorer/Tabs/NotebookV2Tab.ts | 6 +- src/Juno/JunoClient.ts | 67 ++++++++- 20 files changed, 633 insertions(+), 114 deletions(-) create mode 100644 src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.test.tsx create mode 100644 src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx create mode 100644 src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.less create mode 100644 src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.test.tsx create mode 100644 src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx create mode 100644 src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap create mode 100644 src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 44d106669..4d56c53d9 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -7,6 +7,12 @@ export class AuthorizationEndpoints { public static common: string = "https://login.windows.net/"; } +export class CodeOfConductEndpoints { + public static privacyStatement: string = "https://aka.ms/ms-privacy-policy"; + public static codeOfConduct: string = "https://aka.ms/cosmos-code-of-conduct"; + public static termsOfUse: string = "https://aka.ms/ms-terms-of-use"; +} + export class BackendEndpoints { public static localhost: string = "https://localhost:12900"; public static dev: string = "https://ext.documents-dev.windows-int.net"; @@ -116,6 +122,7 @@ export class Features { public static readonly enableTtl = "enablettl"; public static readonly enableNotebooks = "enablenotebooks"; public static readonly enableGalleryPublish = "enablegallerypublish"; + public static readonly enableCodeOfConduct = "enablecodeofconduct"; public static readonly enableLinkInjection = "enablelinkinjection"; public static readonly enableSpark = "enablespark"; public static readonly livyEndpoint = "livyendpoint"; diff --git a/src/Common/ErrorParserUtility.ts b/src/Common/ErrorParserUtility.ts index 04af62f5a..95d1ace34 100644 --- a/src/Common/ErrorParserUtility.ts +++ b/src/Common/ErrorParserUtility.ts @@ -1,69 +1,69 @@ -import * as DataModels from "../Contracts/DataModels"; -import * as ViewModels from "../Contracts/ViewModels"; - -export function replaceKnownError(err: string): string { - if ( - window.dataExplorer.subscriptionType() === ViewModels.SubscriptionType.Internal && - err.indexOf("SharedOffer is Disabled for your account") >= 0 - ) { - return "Database throughput is not supported for internal subscriptions."; - } else if (err.indexOf("Partition key paths must contain only valid") >= 0) { - return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character."; - } - - return err; -} - -export function parse(err: any): DataModels.ErrorDataModel[] { - try { - return _parse(err); - } catch (e) { - return [{ message: JSON.stringify(err) }]; - } -} - -function _parse(err: any): DataModels.ErrorDataModel[] { - var normalizedErrors: DataModels.ErrorDataModel[] = []; - if (err.message && !err.code) { - normalizedErrors.push(err); - } else { - const innerErrors: any[] = _getInnerErrors(err.message); - normalizedErrors = innerErrors.map(innerError => - typeof innerError === "string" ? { message: innerError } : innerError - ); - } - - return normalizedErrors; -} - -function _getInnerErrors(message: string): any[] { - /* - The backend error message has an inner-message which is a stringified object. - - For SQL errors, the "errors" property is an array of SqlErrorDataModel. - Example: - "Message: {"Errors":["Resource with specified id or name already exists"]}\r\nActivityId: 80005000008d40b6a, Request URI: /apps/19000c000c0a0005/services/mctestdocdbprod-MasterService-0-00066ab9937/partitions/900005f9000e676fb8/replicas/13000000000955p" - For non-SQL errors the "Errors" propery is an array of string. - Example: - "Message: {"errors":[{"severity":"Error","location":{"start":7,"end":8},"code":"SC1001","message":"Syntax error, incorrect syntax near '.'."}]}\r\nActivityId: d3300016d4084e310a, Request URI: /apps/12401f9e1df77/services/dc100232b1f44545/partitions/f86f3bc0001a2f78/replicas/13085003638s" - */ - - let innerMessage: any = null; - - const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, ""); - try { - // Multi-Partition error flavor - const regExp = /^(.*)ActivityId: (.*)/g; - const regString = regExp.exec(singleLineMessage); - const innerMessageString = regString[1]; - innerMessage = JSON.parse(innerMessageString); - } catch (e) { - // Single-partition error flavor - const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g; - const regString = regExp.exec(singleLineMessage); - const innerMessageString = regString[1]; - innerMessage = JSON.parse(innerMessageString); - } - - return innerMessage.errors ? innerMessage.errors : innerMessage.Errors; -} +import * as DataModels from "../Contracts/DataModels"; +import * as ViewModels from "../Contracts/ViewModels"; + +export function replaceKnownError(err: string): string { + if ( + window.dataExplorer.subscriptionType() === ViewModels.SubscriptionType.Internal && + err.indexOf("SharedOffer is Disabled for your account") >= 0 + ) { + return "Database throughput is not supported for internal subscriptions."; + } else if (err.indexOf("Partition key paths must contain only valid") >= 0) { + return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character."; + } + + return err; +} + +export function parse(err: any): DataModels.ErrorDataModel[] { + try { + return _parse(err); + } catch (e) { + return [{ message: JSON.stringify(err) }]; + } +} + +function _parse(err: any): DataModels.ErrorDataModel[] { + var normalizedErrors: DataModels.ErrorDataModel[] = []; + if (err.message && !err.code) { + normalizedErrors.push(err); + } else { + const innerErrors: any[] = _getInnerErrors(err.message); + normalizedErrors = innerErrors.map(innerError => + typeof innerError === "string" ? { message: innerError } : innerError + ); + } + + return normalizedErrors; +} + +function _getInnerErrors(message: string): any[] { + /* + The backend error message has an inner-message which is a stringified object. + + For SQL errors, the "errors" property is an array of SqlErrorDataModel. + Example: + "Message: {"Errors":["Resource with specified id or name already exists"]}\r\nActivityId: 80005000008d40b6a, Request URI: /apps/19000c000c0a0005/services/mctestdocdbprod-MasterService-0-00066ab9937/partitions/900005f9000e676fb8/replicas/13000000000955p" + For non-SQL errors the "Errors" propery is an array of string. + Example: + "Message: {"errors":[{"severity":"Error","location":{"start":7,"end":8},"code":"SC1001","message":"Syntax error, incorrect syntax near '.'."}]}\r\nActivityId: d3300016d4084e310a, Request URI: /apps/12401f9e1df77/services/dc100232b1f44545/partitions/f86f3bc0001a2f78/replicas/13085003638s" + */ + + let innerMessage: any = null; + + const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, ""); + try { + // Multi-Partition error flavor + const regExp = /^(.*)ActivityId: (.*)/g; + const regString = regExp.exec(singleLineMessage); + const innerMessageString = regString[1]; + innerMessage = JSON.parse(innerMessageString); + } catch (e) { + // Single-partition error flavor + const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g; + const regString = regExp.exec(singleLineMessage); + const innerMessageString = regString[1]; + innerMessage = JSON.parse(innerMessageString); + } + + return innerMessage.errors ? innerMessage.errors : innerMessage.Errors; +} diff --git a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx index bbbb3d96d..b0f1733f7 100644 --- a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx +++ b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx @@ -49,6 +49,7 @@ export const FeaturePanelComponent: React.FunctionComponent = () => { { key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" }, { key: "feature.enablettl", label: "Enable TTL", value: "true" }, { key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" }, + { key: "feature.enablecodeofconduct", label: "Enable Code Of Conduct Acknowledgement", value: "true" }, { key: "feature.enableLinkInjection", label: "Enable Injecting Notebook Viewer Link into the first cell", diff --git a/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap b/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap index 885ace128..7f4a39014 100644 --- a/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap +++ b/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap @@ -161,6 +161,12 @@ exports[`Feature panel renders all flags 1`] = ` label="Enable Notebook Gallery Publishing" onChange={[Function]} /> + { + let sandbox: sinon.SinonSandbox; + let codeOfConductProps: CodeOfConductComponentProps; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + sandbox.stub(JunoClient.prototype, "acceptCodeOfConduct").returns({ + status: HttpStatusCodes.OK, + data: true + } as IJunoResponse); + const junoClient = new JunoClient(undefined); + codeOfConductProps = { + junoClient: junoClient, + onAcceptCodeOfConduct: jest.fn() + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("renders", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it("onAcceptedCodeOfConductCalled", async () => { + const wrapper = shallow(); + wrapper + .find(".genericPaneSubmitBtn") + .first() + .simulate("click"); + await Promise.resolve(); + expect(codeOfConductProps.onAcceptCodeOfConduct).toBeCalled(); + }); +}); diff --git a/src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx b/src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx new file mode 100644 index 000000000..02dc407e0 --- /dev/null +++ b/src/Explorer/Controls/NotebookGallery/CodeOfConductComponent.tsx @@ -0,0 +1,112 @@ +import * as React from "react"; +import { JunoClient } from "../../../Juno/JunoClient"; +import { HttpStatusCodes, CodeOfConductEndpoints } from "../../../Common/Constants"; +import * as Logger from "../../../Common/Logger"; +import { logConsoleError } from "../../../Utils/NotificationConsoleUtils"; +import { Stack, Text, Checkbox, PrimaryButton, Link } from "office-ui-fabric-react"; + +export interface CodeOfConductComponentProps { + junoClient: JunoClient; + onAcceptCodeOfConduct: (result: boolean) => void; +} + +interface CodeOfConductComponentState { + readCodeOfConduct: boolean; +} + +export class CodeOfConductComponent extends React.Component { + 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); + + this.state = { + 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 }; + } + + private async acceptCodeOfConduct(): Promise { + 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`); + } + + this.props.onAcceptCodeOfConduct(response.data); + } catch (error) { + const message = `Failed to accept code of conduct: ${error}`; + Logger.logError(message, "CodeOfConductComponent/acceptCodeOfConduct"); + logConsoleError(message); + } + } + + private onChangeCheckbox = (): void => { + this.setState({ readCodeOfConduct: !this.state.readCodeOfConduct }); + }; + + public render(): JSX.Element { + return ( + + + {this.descriptionPara1} + + + + {this.descriptionPara2} + + + + + {this.descriptionPara3} + + {this.link1.label} + + {" and "} + + {this.link2.label} + + + + + + + + + + await this.acceptCodeOfConduct()} + tabIndex={0} + className="genericPaneSubmitBtn" + text="Continue" + disabled={!this.state.readCodeOfConduct} + /> + + + ); + } +} diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx index 34758dc1d..0241f76eb 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx @@ -15,7 +15,7 @@ import { } from "office-ui-fabric-react"; import * as React from "react"; import * as Logger from "../../../Common/Logger"; -import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; +import { IGalleryItem, JunoClient, IJunoResponse, IPublicGalleryData } from "../../../Juno/JunoClient"; import * as GalleryUtils from "../../../Utils/GalleryUtils"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; @@ -24,6 +24,8 @@ import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/Gallery import "./GalleryViewerComponent.less"; import { HttpStatusCodes } from "../../../Common/Constants"; import Explorer from "../../Explorer"; +import { CodeOfConductComponent } from "./CodeOfConductComponent"; +import { InfoComponent } from "./InfoComponent/InfoComponent"; export interface GalleryViewerComponentProps { container?: Explorer; @@ -60,6 +62,7 @@ interface GalleryViewerComponentState { sortBy: SortBy; searchText: string; dialogProps: DialogProps; + isCodeOfConductAccepted: boolean; } interface GalleryTabInfo { @@ -86,6 +89,7 @@ export class GalleryViewerComponent extends React.Component { + this.setState({ isCodeOfConductAccepted: result }); + }} + /> + ) : ( + this.createTabContent(data) + ); + } + private createTabContent(data: IGalleryItem[]): JSX.Element { return ( @@ -187,8 +227,12 @@ export class GalleryViewerComponent extends React.Component + {this.props.container?.isGalleryPublishEnabled() && ( + + + + )} - {data && this.createCardsTabContent(data)} ); @@ -254,12 +298,19 @@ export class GalleryViewerComponent extends React.Component { if (!offline) { try { - const response = await this.props.junoClient.getPublicNotebooks(); + let response: IJunoResponse | IJunoResponse; + if (this.props.container.isCodeOfConductEnabled()) { + response = await this.props.junoClient.fetchPublicNotebooks(); + this.isCodeOfConductAccepted = response.data?.metadata.acceptedCodeOfConduct; + this.publicNotebooks = response.data?.notebooksData; + } else { + response = await this.props.junoClient.getPublicNotebooks(); + this.publicNotebooks = response.data; + } + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { throw new Error(`Received HTTP ${response.status} when loading public notebooks`); } - - this.publicNotebooks = response.data; } catch (error) { const message = `Failed to load public notebooks: ${error}`; Logger.logError(message, "GalleryViewerComponent/loadPublicNotebooks"); @@ -268,7 +319,8 @@ export class GalleryViewerComponent extends React.Component { + it("renders", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx new file mode 100644 index 000000000..f14b7c350 --- /dev/null +++ b/src/Explorer/Controls/NotebookGallery/InfoComponent/InfoComponent.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { Icon, Label, Stack, HoverCard, HoverCardType, Link } from "office-ui-fabric-react"; +import { CodeOfConductEndpoints } from "../../../../Common/Constants"; +import "./InfoComponent.less"; + +export class InfoComponent extends React.Component { + private getInfoPanel = (iconName: string, labelText: string, url: string): JSX.Element => { + return ( + +
+ + +
+ + ); + }; + + private onHover = (): JSX.Element => { + return ( + + {this.getInfoPanel("Script", "Code of Conduct", CodeOfConductEndpoints.codeOfConduct)} + + {this.getInfoPanel("RedEye", "Privacy Statement", CodeOfConductEndpoints.privacyStatement)} + + + {this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)} + + + ); + }; + + public render(): JSX.Element { + return ( + +
+ + +
+
+ ); + } +} diff --git a/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap new file mode 100644 index 000000000..4aea859f5 --- /dev/null +++ b/src/Explorer/Controls/NotebookGallery/InfoComponent/__snapshots__/InfoComponent.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InfoComponent renders 1`] = ` + +
+ + + Help + +
+
+`; diff --git a/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap new file mode 100644 index 000000000..3362852dd --- /dev/null +++ b/src/Explorer/Controls/NotebookGallery/__snapshots__/CodeOfConductComponent.test.tsx.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CodeOfConductComponent renders 1`] = ` + + + + Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement + + + + + Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB. + + + + + In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the + + code of conduct + + and + + privacy statement + + + + + + + + + + +`; diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 59fa442de..1761cfa7b 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -206,6 +206,7 @@ export default class Explorer { // features public isGalleryPublishEnabled: ko.Computed; + public isCodeOfConductEnabled: ko.Computed; public isLinkInjectionEnabled: ko.Computed; public isGitHubPaneEnabled: ko.Observable; public isPublishNotebookPaneEnabled: ko.Observable; @@ -409,6 +410,9 @@ export default class Explorer { this.isGalleryPublishEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableGalleryPublish) ); + this.isCodeOfConductEnabled = ko.computed(() => + this.isFeatureEnabled(Constants.Features.enableCodeOfConduct) + ); this.isLinkInjectionEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableLinkInjection) ); @@ -2356,9 +2360,15 @@ export default class Explorer { return Promise.resolve(false); } - public publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): void { + public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise { if (this.notebookManager) { - this.notebookManager.openPublishNotebookPane(name, content, parentDomElement, this.isLinkInjectionEnabled()); + await this.notebookManager.openPublishNotebookPane( + name, + content, + parentDomElement, + this.isCodeOfConductEnabled(), + this.isLinkInjectionEnabled() + ); this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; this.isPublishNotebookPaneEnabled(true); } diff --git a/src/Explorer/Notebook/NotebookManager.ts b/src/Explorer/Notebook/NotebookManager.ts index b1d606985..582b4fda8 100644 --- a/src/Explorer/Notebook/NotebookManager.ts +++ b/src/Explorer/Notebook/NotebookManager.ts @@ -108,13 +108,21 @@ export default class NotebookManager { this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); } - public openPublishNotebookPane( + public async openPublishNotebookPane( name: string, content: string | ImmutableNotebook, parentDomElement: HTMLElement, + isCodeOfConductEnabled: boolean, isLinkInjectionEnabled: boolean - ): void { - this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement, isLinkInjectionEnabled); + ): Promise { + await this.publishNotebookPaneAdapter.open( + name, + getFullName(), + content, + parentDomElement, + isCodeOfConductEnabled, + isLinkInjectionEnabled + ); } // Octokit's error handler uses any diff --git a/src/Explorer/Notebook/NotebookUtil.test.ts b/src/Explorer/Notebook/NotebookUtil.test.ts index e43aa2c9c..2f6c1136f 100644 --- a/src/Explorer/Notebook/NotebookUtil.test.ts +++ b/src/Explorer/Notebook/NotebookUtil.test.ts @@ -43,10 +43,8 @@ const notebookRecord = makeNotebookRecord({ source: 'display(HTML("

Sample html

"))', outputs: List.of({ data: Object.freeze({ - data: { - "text/html": "

Sample output

", - "text/plain": "" - } + "text/html": "

Sample output

", + "text/plain": "" } as MediaBundle), output_type: "display_data", metadata: undefined diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts index be4e38480..27ad34a53 100644 --- a/src/Explorer/Notebook/NotebookUtil.ts +++ b/src/Explorer/Notebook/NotebookUtil.ts @@ -1,5 +1,5 @@ import path from "path"; -import { ImmutableNotebook, ImmutableCodeCell, ImmutableOutput } from "@nteract/commutable"; +import { ImmutableNotebook, ImmutableCodeCell } from "@nteract/commutable"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; import { StringUtils } from "../../Utils/StringUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils"; @@ -102,25 +102,19 @@ export class NotebookUtil { } public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number { - let codeCellCount = -1; + let codeCellIndex = 0; for (let i = 0; i < notebookObject.cellOrder.size; i++) { const cellId = notebookObject.cellOrder.get(i); if (cellId) { const cell = notebookObject.cellMap.get(cellId); - if (cell && cell.cell_type === "code") { - codeCellCount++; - const codeCell = cell as ImmutableCodeCell; - if (codeCell.outputs) { - const displayOutput = codeCell.outputs.find((output: ImmutableOutput) => { - if (output.output_type === "display_data" || output.output_type === "execute_result") { - return true; - } - return false; - }); - if (displayOutput) { - return codeCellCount; - } + if (cell?.cell_type === "code") { + const displayOutput = (cell as ImmutableCodeCell)?.outputs?.find( + output => output.output_type === "display_data" || output.output_type === "execute_result" + ); + if (displayOutput) { + return codeCellIndex; } + codeCellIndex++; } } } diff --git a/src/Explorer/Panes/GenericRightPaneComponent.tsx b/src/Explorer/Panes/GenericRightPaneComponent.tsx index 4b17bdc8f..f4e4d2285 100644 --- a/src/Explorer/Panes/GenericRightPaneComponent.tsx +++ b/src/Explorer/Panes/GenericRightPaneComponent.tsx @@ -17,6 +17,7 @@ export interface GenericRightPaneProps { onSubmit: () => void; submitButtonText: string; title: string; + isSubmitButtonVisible?: boolean; } export interface GenericRightPaneState { @@ -108,6 +109,7 @@ export class GenericRightPaneComponent extends React.Component
; @@ -26,6 +28,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { private imageSrc: string; private notebookObject: ImmutableNotebook; private parentDomElement: HTMLElement; + private isCodeOfConductAccepted: boolean; private isLinkInjectionEnabled: boolean; constructor(private container: Explorer, private junoClient: JunoClient) { @@ -49,7 +52,8 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { title: "Publish to gallery", submitButtonText: "Publish", onClose: () => this.close(), - onSubmit: () => this.submit() + onSubmit: () => this.submit(), + isSubmitButtonVisible: this.isCodeOfConductAccepted }; return ; @@ -59,13 +63,31 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { window.requestAnimationFrame(() => this.parameters(Date.now())); } - public open( + public async open( name: string, author: string, notebookContent: string | ImmutableNotebook, parentDomElement: HTMLElement, + isCodeOfConductEnabled: boolean, isLinkInjectionEnabled: boolean - ): void { + ): Promise { + if (isCodeOfConductEnabled) { + try { + const response = await this.junoClient.isCodeOfConductAccepted(); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { + throw new Error(`Received HTTP ${response.status} when accepting code of conduct`); + } + + this.isCodeOfConductAccepted = response.data; + } catch (error) { + const message = `Failed to check if code of conduct was accepted: ${error}`; + Logger.logError(message, "PublishNotebookPaneAdapter/isCodeOfConductAccepted"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + } + } else { + this.isCodeOfConductAccepted = true; + } + this.name = name; this.author = author; if (typeof notebookContent === "string") { @@ -108,11 +130,9 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { this.content, this.isLinkInjectionEnabled ); - if (!response.data) { - throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`); + if (response.data) { + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`); } - - NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`); } catch (error) { this.formError = `Failed to publish ${this.name} to gallery`; this.formErrorDetail = `${error}`; @@ -162,7 +182,19 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { clearFormError: this.clearFormError }; - return ; + return !this.isCodeOfConductAccepted ? ( +
+ { + this.isCodeOfConductAccepted = true; + this.triggerRender(); + }} + /> +
+ ) : ( + + ); }; private reset = (): void => { @@ -178,5 +210,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { this.imageSrc = undefined; this.notebookObject = undefined; this.parentDomElement = undefined; + this.isCodeOfConductAccepted = undefined; + this.isLinkInjectionEnabled = undefined; }; } diff --git a/src/Explorer/Tabs/NotebookV2Tab.ts b/src/Explorer/Tabs/NotebookV2Tab.ts index 7f6320f38..3b01ead38 100644 --- a/src/Explorer/Tabs/NotebookV2Tab.ts +++ b/src/Explorer/Tabs/NotebookV2Tab.ts @@ -166,7 +166,7 @@ export default class NotebookTabV2 extends TabsBase { }, { iconName: "PublishContent", - onCommandClick: () => this.publishToGallery(), + onCommandClick: async () => await this.publishToGallery(), commandButtonLabel: publishLabel, hasPopup: false, disabled: false, @@ -456,9 +456,9 @@ export default class NotebookTabV2 extends TabsBase { ); } - private publishToGallery = () => { + private publishToGallery = async () => { const notebookContent = this.notebookComponentAdapter.getContent(); - this.container.publishNotebook( + await this.container.publishNotebook( notebookContent.name, notebookContent.content, this.notebookComponentAdapter.getNotebookParentElement() diff --git a/src/Juno/JunoClient.ts b/src/Juno/JunoClient.ts index 1f5677de6..fbc59e85d 100644 --- a/src/Juno/JunoClient.ts +++ b/src/Juno/JunoClient.ts @@ -39,6 +39,15 @@ export interface IGalleryItem { newCellId: string; } +export interface IPublicGalleryData { + metadata: IPublicGalleryMetaData; + notebooksData: IGalleryItem[]; +} + +export interface IPublicGalleryMetaData { + acceptedCodeOfConduct: boolean; +} + export interface IUserGallery { favorites: string[]; published: string[]; @@ -163,6 +172,61 @@ export class JunoClient { return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`); } + // will be renamed once feature.enableCodeOfConduct flag is removed + public async fetchPublicNotebooks(): Promise> { + const url = `${this.getNotebooksAccountUrl()}/gallery/public`; + const response = await window.fetch(url, { + method: "PATCH", + headers: JunoClient.getHeaders() + }); + + let data: IPublicGalleryData; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public async acceptCodeOfConduct(): Promise> { + const url = `${this.getNotebooksAccountUrl()}/gallery/acceptCodeOfConduct`; + const response = await window.fetch(url, { + method: "PATCH", + headers: JunoClient.getHeaders() + }); + + let data: boolean; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public async isCodeOfConductAccepted(): Promise> { + const url = `${this.getNotebooksAccountUrl()}/gallery/isCodeOfConductAccepted`; + const response = await window.fetch(url, { + method: "PATCH", + headers: JunoClient.getHeaders() + }); + + let data: boolean; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + public async getNotebookInfo(id: string): Promise> { const response = await window.fetch(this.getNotebookInfoUrl(id)); @@ -299,7 +363,6 @@ export class JunoClient { const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, { method: "PUT", headers: JunoClient.getHeaders(), - body: isLinkInjectionEnabled ? JSON.stringify({ name, @@ -323,6 +386,8 @@ export class JunoClient { let data: IGalleryItem; if (response.status === HttpStatusCodes.OK) { data = await response.json(); + } else { + throw new Error(`HTTP status ${response.status} thrown. ${(await response.json()).Message}`); } return { From 5886db81e96d174ab55455c324852ea40442effb Mon Sep 17 00:00:00 2001 From: Tanuj Mittal Date: Tue, 11 Aug 2020 09:27:57 -0700 Subject: [PATCH 09/13] Copy To functionality for notebooks (#141) * Add Copy To functionality for notebooks * Fix formatting * Fix linting errors * Fixes * Fix build failure * Rebase and address feedback * Increase test coverage --- .../GalleryViewerComponent.tsx | 11 +- src/Explorer/Explorer.ts | 14 +- .../Notebook/NotebookContentClient.ts | 9 +- src/Explorer/Notebook/NotebookManager.ts | 16 + src/Explorer/Notebook/NotebookUtil.test.ts | 26 +- src/Explorer/Notebook/NotebookUtil.ts | 40 +++ src/Explorer/Panes/CopyNotebookPane.tsx | 283 ++++++++++++++++++ .../Panes/PublishNotebookPaneAdapter.tsx | 1 + .../PublishNotebookPaneComponent.test.tsx | 1 + .../Panes/PublishNotebookPaneComponent.tsx | 19 ++ ...PublishNotebookPaneComponent.test.tsx.snap | 9 + src/Explorer/Tabs/NotebookV2Tab.ts | 22 ++ src/Explorer/Tree/ResourceTreeAdapter.tsx | 20 +- src/GitHub/GitHubClient.ts | 7 + src/GitHub/GitHubContentProvider.ts | 66 ++-- src/Utils/Base64Utils.test.ts | 11 + src/Utils/Base64Utils.ts | 7 + src/explorer.html | 4 + 18 files changed, 530 insertions(+), 36 deletions(-) create mode 100644 src/Explorer/Panes/CopyNotebookPane.tsx create mode 100644 src/Utils/Base64Utils.test.ts create mode 100644 src/Utils/Base64Utils.ts diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx index 0241f76eb..5922aa017 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx @@ -385,12 +385,11 @@ export class GalleryViewerComponent extends React.Component tag.toUpperCase()) - ]; + const searchData: string[] = [item.author.toUpperCase(), item.description.toUpperCase(), item.name.toUpperCase()]; + + if (item.tags) { + searchData.push(...item.tags.map(tag => tag.toUpperCase())); + } for (const data of searchData) { if (data?.indexOf(toSearch) !== -1) { diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 1761cfa7b..15be7c0c9 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -203,6 +203,7 @@ export default class Explorer { public setupNotebooksPane: SetupNotebooksPane; public gitHubReposPane: ContextualPaneBase; public publishNotebookPaneAdapter: ReactAdapter; + public copyNotebookPaneAdapter: ReactAdapter; // features public isGalleryPublishEnabled: ko.Computed; @@ -210,6 +211,7 @@ export default class Explorer { public isLinkInjectionEnabled: ko.Computed; public isGitHubPaneEnabled: ko.Observable; public isPublishNotebookPaneEnabled: ko.Observable; + public isCopyNotebookPaneEnabled: ko.Observable; public isHostedDataExplorerEnabled: ko.Computed; public isRightPanelV2Enabled: ko.Computed; public canExceedMaximumValue: ko.Computed; @@ -418,6 +420,7 @@ export default class Explorer { ); this.isGitHubPaneEnabled = ko.observable(false); this.isPublishNotebookPaneEnabled = ko.observable(false); + this.isCopyNotebookPaneEnabled = ko.observable(false); this.canExceedMaximumValue = ko.computed(() => this.isFeatureEnabled(Constants.Features.canExceedMaximumValue) @@ -2305,7 +2308,7 @@ export default class Explorer { return _.find(offers, (offer: DataModels.Offer) => offer.resource === resourceId); } - private uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { + public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to upload notebook, but notebook is not enabled"; Logger.logError(error, "Explorer/uploadFile"); @@ -2374,6 +2377,14 @@ export default class Explorer { } } + public copyNotebook(name: string, content: string): void { + if (this.notebookManager) { + this.notebookManager.openCopyNotebookPane(name, content); + this.copyNotebookPaneAdapter = this.notebookManager.copyNotebookPaneAdapter; + this.isCopyNotebookPaneEnabled(true); + } + } + public showOkModalDialog(title: string, msg: string): void { this._dialogProps({ isModal: true, @@ -2730,6 +2741,7 @@ export default class Explorer { } await this.resourceTree.initialize(); + this.notebookManager?.refreshPinnedRepos(); if (this.notebookToImport) { this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); } diff --git a/src/Explorer/Notebook/NotebookContentClient.ts b/src/Explorer/Notebook/NotebookContentClient.ts index 4fe92b435..b4036fadf 100644 --- a/src/Explorer/Notebook/NotebookContentClient.ts +++ b/src/Explorer/Notebook/NotebookContentClient.ts @@ -89,7 +89,7 @@ export class NotebookContentClient { throw new Error(`Parent must be a directory: ${parent}`); } - const filepath = `${parent.path}/${name}`; + const filepath = NotebookUtil.getFilePath(parent.path, name); if (await this.checkIfFilepathExists(filepath)) { throw new Error(`File already exists: ${filepath}`); } @@ -116,12 +116,7 @@ export class NotebookContentClient { } private async checkIfFilepathExists(filepath: string): Promise { - const basename = filepath.split("/").pop(); - let parentDirPath = filepath - .split(basename) - .shift() - .replace(/\/$/, ""); // no trailling slash - + const parentDirPath = NotebookUtil.getParentPath(filepath); const items = await this.fetchNotebookFiles(parentDirPath); return items.some(value => FileSystemUtil.isPathEqual(value.path, filepath)); } diff --git a/src/Explorer/Notebook/NotebookManager.ts b/src/Explorer/Notebook/NotebookManager.ts index 582b4fda8..50830ee8a 100644 --- a/src/Explorer/Notebook/NotebookManager.ts +++ b/src/Explorer/Notebook/NotebookManager.ts @@ -25,6 +25,7 @@ import { getFullName } from "../../Utils/UserUtils"; import { ImmutableNotebook } from "@nteract/commutable"; import Explorer from "../Explorer"; import { ContextualPaneBase } from "../Panes/ContextualPaneBase"; +import { CopyNotebookPaneAdapter } from "../Panes/CopyNotebookPane"; export interface NotebookManagerOptions { container: Explorer; @@ -49,6 +50,7 @@ export default class NotebookManager { public gitHubReposPane: ContextualPaneBase; public publishNotebookPaneAdapter: PublishNotebookPaneAdapter; + public copyNotebookPaneAdapter: CopyNotebookPaneAdapter; public initialize(params: NotebookManagerOptions): void { this.params = params; @@ -90,6 +92,12 @@ export default class NotebookManager { this.publishNotebookPaneAdapter = new PublishNotebookPaneAdapter(this.params.container, this.junoClient); } + this.copyNotebookPaneAdapter = new CopyNotebookPaneAdapter( + this.params.container, + this.junoClient, + this.gitHubOAuthService + ); + this.gitHubOAuthService.getTokenObservable().subscribe(token => { this.gitHubClient.setToken(token?.access_token); @@ -108,6 +116,10 @@ export default class NotebookManager { this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); } + public refreshPinnedRepos(): void { + this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); + } + public async openPublishNotebookPane( name: string, content: string | ImmutableNotebook, @@ -125,6 +137,10 @@ export default class NotebookManager { ); } + public openCopyNotebookPane(name: string, content: string): void { + this.copyNotebookPaneAdapter.open(name, content); + } + // Octokit's error handler uses any // eslint-disable-next-line @typescript-eslint/no-explicit-any private onGitHubClientError = (error: any): void => { diff --git a/src/Explorer/Notebook/NotebookUtil.test.ts b/src/Explorer/Notebook/NotebookUtil.test.ts index 2f6c1136f..2cfa7eadc 100644 --- a/src/Explorer/Notebook/NotebookUtil.test.ts +++ b/src/Explorer/Notebook/NotebookUtil.test.ts @@ -13,8 +13,10 @@ import { List, Map } from "immutable"; const fileName = "file"; const notebookName = "file.ipynb"; -const filePath = `folder/${fileName}`; -const notebookPath = `folder/${notebookName}`; +const folderPath = "folder"; +const filePath = `${folderPath}/${fileName}`; +const notebookPath = `${folderPath}/${notebookName}`; +const gitHubFolderUri = GitHubUtils.toContentUri("owner", "repo", "branch", folderPath); const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath); const gitHubNotebookUri = GitHubUtils.toContentUri("owner", "repo", "branch", notebookPath); const notebookRecord = makeNotebookRecord({ @@ -80,6 +82,26 @@ describe("NotebookUtil", () => { }); }); + describe("getFilePath", () => { + it("works for jupyter file paths", () => { + expect(NotebookUtil.getFilePath(folderPath, fileName)).toEqual(filePath); + }); + + it("works for github file uris", () => { + expect(NotebookUtil.getFilePath(gitHubFolderUri, fileName)).toEqual(gitHubFileUri); + }); + }); + + describe("getParentPath", () => { + it("works for jupyter file paths", () => { + expect(NotebookUtil.getParentPath(filePath)).toEqual(folderPath); + }); + + it("works for github file uris", () => { + expect(NotebookUtil.getParentPath(gitHubFileUri)).toEqual(gitHubFolderUri); + }); + }); + describe("getName", () => { it("works for jupyter file paths", () => { expect(NotebookUtil.getName(filePath)).toEqual(fileName); diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts index 27ad34a53..3b076c55b 100644 --- a/src/Explorer/Notebook/NotebookUtil.ts +++ b/src/Explorer/Notebook/NotebookUtil.ts @@ -70,6 +70,46 @@ export class NotebookUtil { }; } + public static getFilePath(path: string, fileName: string): string { + const contentInfo = GitHubUtils.fromContentUri(path); + if (contentInfo) { + let path = fileName; + if (contentInfo.path) { + path = `${contentInfo.path}/${path}`; + } + return GitHubUtils.toContentUri(contentInfo.owner, contentInfo.repo, contentInfo.branch, path); + } + + return `${path}/${fileName}`; + } + + public static getParentPath(filepath: string): undefined | string { + const basename = NotebookUtil.getName(filepath); + if (basename) { + const contentInfo = GitHubUtils.fromContentUri(filepath); + if (contentInfo) { + const parentPath = contentInfo.path.split(basename).shift(); + if (parentPath === undefined) { + return undefined; + } + + return GitHubUtils.toContentUri( + contentInfo.owner, + contentInfo.repo, + contentInfo.branch, + parentPath.replace(/\/$/, "") // no trailling slash + ); + } + + const parentPath = filepath.split(basename).shift(); + if (parentPath) { + return parentPath.replace(/\/$/, ""); // no trailling slash + } + } + + return undefined; + } + public static getName(path: string): undefined | string { let relativePath: string = path; const contentInfo = GitHubUtils.fromContentUri(path); diff --git a/src/Explorer/Panes/CopyNotebookPane.tsx b/src/Explorer/Panes/CopyNotebookPane.tsx new file mode 100644 index 000000000..62ad1c6c7 --- /dev/null +++ b/src/Explorer/Panes/CopyNotebookPane.tsx @@ -0,0 +1,283 @@ +import ko from "knockout"; +import * as React from "react"; +import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; +import * as Logger from "../../Common/Logger"; +import { JunoClient, IPinnedRepo } from "../../Juno/JunoClient"; +import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"; +import Explorer from "../Explorer"; +import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent"; +import { + Stack, + Label, + Text, + Dropdown, + IDropdownProps, + IDropdownOption, + SelectableOptionMenuItemType, + IRenderFunction, + ISelectableOption +} from "office-ui-fabric-react"; +import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService"; +import { HttpStatusCodes } from "../../Common/Constants"; +import * as GitHubUtils from "../../Utils/GitHubUtils"; +import { NotebookContentItemType, NotebookContentItem } from "../Notebook/NotebookContentItem"; +import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; + +interface Location { + type: "MyNotebooks" | "GitHub"; + + // GitHub + owner?: string; + repo?: string; + branch?: string; +} + +export class CopyNotebookPaneAdapter implements ReactAdapter { + private static readonly BranchNameWhiteSpace = " "; + + parameters: ko.Observable; + private isOpened: boolean; + private isExecuting: boolean; + private formError: string; + private formErrorDetail: string; + private name: string; + private content: string; + private pinnedRepos: IPinnedRepo[]; + private selectedLocation: Location; + + constructor( + private container: Explorer, + private junoClient: JunoClient, + private gitHubOAuthService: GitHubOAuthService + ) { + this.parameters = ko.observable(Date.now()); + this.reset(); + this.triggerRender(); + } + + public renderComponent(): JSX.Element { + if (!this.isOpened) { + return undefined; + } + + const props: GenericRightPaneProps = { + container: this.container, + content: this.createContent(), + formError: this.formError, + formErrorDetail: this.formErrorDetail, + id: "copynotebookpane", + isExecuting: this.isExecuting, + title: "Copy notebook", + submitButtonText: "OK", + onClose: () => this.close(), + onSubmit: () => this.submit() + }; + + return ; + } + + public triggerRender(): void { + window.requestAnimationFrame(() => this.parameters(Date.now())); + } + + public async open(name: string, content: string): Promise { + this.name = name; + this.content = content; + + this.isOpened = true; + this.triggerRender(); + + if (this.gitHubOAuthService.isLoggedIn()) { + const response = await this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { + const message = `Received HTTP ${response.status} when fetching pinned repos`; + Logger.logError(message, "CopyNotebookPaneAdapter/submit"); + NotificationConsoleUtils.logConsoleError(message); + } + + if (response.data?.length > 0) { + this.pinnedRepos = response.data; + this.triggerRender(); + } + } + } + + public close(): void { + this.reset(); + this.triggerRender(); + } + + public async submit(): Promise { + let destination: string = this.selectedLocation?.type; + let clearMessage: () => void; + this.isExecuting = true; + this.triggerRender(); + + try { + if (!this.selectedLocation) { + throw new Error(`No location selected`); + } + + if (this.selectedLocation.type === "GitHub") { + destination = `${destination} - ${GitHubUtils.toRepoFullName( + this.selectedLocation.owner, + this.selectedLocation.repo + )} - ${this.selectedLocation.branch}`; + } + + clearMessage = NotificationConsoleUtils.logConsoleProgress(`Copying ${this.name} to ${destination}`); + + const notebookContentItem = await this.copyNotebook(this.selectedLocation); + if (!notebookContentItem) { + throw new Error(`Failed to upload ${this.name}`); + } + + NotificationConsoleUtils.logConsoleInfo(`Successfully copied ${this.name} to ${destination}`); + } catch (error) { + this.formError = `Failed to copy ${this.name} to ${destination}`; + this.formErrorDetail = `${error}`; + + const message = `${this.formError}: ${this.formErrorDetail}`; + Logger.logError(message, "CopyNotebookPaneAdapter/submit"); + NotificationConsoleUtils.logConsoleError(message); + return; + } finally { + clearMessage && clearMessage(); + this.isExecuting = false; + this.triggerRender(); + } + + this.close(); + } + + private copyNotebook = async (location: Location): Promise => { + let parent: NotebookContentItem; + switch (location.type) { + case "MyNotebooks": + parent = { + name: ResourceTreeAdapter.MyNotebooksTitle, + path: this.container.getNotebookBasePath(), + type: NotebookContentItemType.Directory + }; + break; + + case "GitHub": + parent = { + name: ResourceTreeAdapter.GitHubReposTitle, + path: GitHubUtils.toContentUri( + this.selectedLocation.owner, + this.selectedLocation.repo, + this.selectedLocation.branch, + "" + ), + type: NotebookContentItemType.Directory + }; + break; + + default: + throw new Error(`Unsupported location type ${location.type}`); + } + + return this.container.uploadFile(this.name, this.content, parent); + }; + + private createContent = (): JSX.Element => { + const dropDownProps: IDropdownProps = { + label: "Location", + ariaLabel: "Location", + placeholder: "Select an option", + onRenderTitle: this.onRenderDropDownTitle, + onRenderOption: this.onRenderDropDownOption, + options: this.getDropDownOptions(), + onChange: this.onDropDownChange + }; + + return ( +
+ + + + {this.name} + + + + +
+ ); + }; + + private onRenderDropDownTitle: IRenderFunction = (options: IDropdownOption[]): JSX.Element => { + return {options.length && options[0].title}; + }; + + private onRenderDropDownOption: IRenderFunction = (option: ISelectableOption): JSX.Element => { + return {option.text}; + }; + + private getDropDownOptions = (): IDropdownOption[] => { + const options: IDropdownOption[] = []; + + options.push({ + key: "MyNotebooks-Item", + text: ResourceTreeAdapter.MyNotebooksTitle, + title: ResourceTreeAdapter.MyNotebooksTitle, + data: { + type: "MyNotebooks" + } as Location + }); + + if (this.pinnedRepos && this.pinnedRepos.length > 0) { + options.push({ + key: "GitHub-Header-Divider", + text: undefined, + itemType: SelectableOptionMenuItemType.Divider + }); + + options.push({ + key: "GitHub-Header", + text: ResourceTreeAdapter.GitHubReposTitle, + itemType: SelectableOptionMenuItemType.Header + }); + + this.pinnedRepos.forEach(pinnedRepo => { + const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name); + options.push({ + key: `GitHub-Repo-${repoFullName}`, + text: repoFullName, + disabled: true + }); + + pinnedRepo.branches.forEach(branch => + options.push({ + key: `GitHub-Repo-${repoFullName}-${branch.name}`, + text: `${CopyNotebookPaneAdapter.BranchNameWhiteSpace}${branch.name}`, + title: `${repoFullName} - ${branch.name}`, + data: { + type: "GitHub", + owner: pinnedRepo.owner, + repo: pinnedRepo.name, + branch: branch.name + } as Location + }) + ); + }); + } + + return options; + }; + + private onDropDownChange = (_: React.FormEvent, option?: IDropdownOption): void => { + this.selectedLocation = option?.data; + }; + + private reset = (): void => { + this.isOpened = false; + this.isExecuting = false; + this.formError = undefined; + this.formErrorDetail = undefined; + this.name = undefined; + this.content = undefined; + this.pinnedRepos = undefined; + this.selectedLocation = undefined; + }; +} diff --git a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx index 2848db120..3af78a4c2 100644 --- a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx @@ -175,6 +175,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { notebookCreatedDate: new Date().toISOString(), notebookObject: this.notebookObject, notebookParentDomElement: this.parentDomElement, + onChangeName: (newValue: string) => (this.name = newValue), onChangeDescription: (newValue: string) => (this.description = newValue), onChangeTags: (newValue: string) => (this.tags = newValue), onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue), diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx index 8f2aae7f7..59762d955 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx @@ -12,6 +12,7 @@ describe("PublishNotebookPaneComponent", () => { notebookCreatedDate: "2020-07-17T00:00:00Z", notebookObject: undefined, notebookParentDomElement: undefined, + onChangeName: undefined, onChangeDescription: undefined, onChangeTags: undefined, onChangeImageSrc: undefined, diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx index 29aab162c..986b42e2e 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx @@ -15,6 +15,7 @@ export interface PublishNotebookPaneProps { notebookCreatedDate: string; notebookObject: ImmutableNotebook; notebookParentDomElement: HTMLElement; + onChangeName: (newValue: string) => void; onChangeDescription: (newValue: string) => void; onChangeTags: (newValue: string) => void; onChangeImageSrc: (newValue: string) => void; @@ -24,6 +25,7 @@ export interface PublishNotebookPaneProps { interface PublishNotebookPaneState { type: string; + notebookName: string; notebookDescription: string; notebookTags: string; imageSrc: string; @@ -40,6 +42,7 @@ export class PublishNotebookPaneComponent extends React.Component { + this.props.onChangeName(newValue); + this.setState({ notebookName: newValue }); + } + }; + this.descriptionProps = { label: "Description", ariaLabel: "Description", @@ -245,6 +260,10 @@ export class PublishNotebookPaneComponent extends React.Component{this.descriptionPara2} + + + + diff --git a/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap b/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap index 744060f85..f7499f6af 100644 --- a/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap +++ b/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap @@ -22,6 +22,15 @@ exports[`PublishNotebookPaneComponent renders 1`] = ` Would you like to publish and share "SampleNotebook" to the gallery? + + + this.copyNotebook(), + commandButtonLabel: copyToLabel, + hasPopup: false, + disabled: false, + ariaLabel: copyToLabel + }, { iconName: "PublishContent", onCommandClick: async () => await this.publishToGallery(), @@ -465,6 +475,18 @@ export default class NotebookTabV2 extends TabsBase { ); }; + private copyNotebook = () => { + const notebookContent = this.notebookComponentAdapter.getContent(); + let content: string; + if (typeof notebookContent.content === "string") { + content = notebookContent.content; + } else { + content = stringifyNotebook(toJS(notebookContent.content)); + } + + this.container.copyNotebook(notebookContent.name, content); + }; + private traceTelemetry(actionType: number) { TelemetryProcessor.trace(actionType, ActionModifiers.Mark, { databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name, diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 15f477103..2564a4930 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -7,6 +7,7 @@ import * as ViewModels from "../../Contracts/ViewModels"; import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; import { ResourceTreeContextMenuButtonFactory } from "../ContextMenuButtonFactory"; import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity"; +import CopyIcon from "../../../images/notebook/Notebook-copy.svg"; import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg"; import CollectionIcon from "../../../images/tree-collection.svg"; import DeleteIcon from "../../../images/delete.svg"; @@ -33,6 +34,9 @@ import TabsBase from "../Tabs/TabsBase"; import { userContext } from "../../UserContext"; export class ResourceTreeAdapter implements ReactAdapter { + public static readonly MyNotebooksTitle = "My Notebooks"; + public static readonly GitHubReposTitle = "GitHub repos"; + private static readonly DataTitle = "DATA"; private static readonly NotebooksTitle = "NOTEBOOKS"; private static readonly PseudoDirPath = "PsuedoDir"; @@ -104,7 +108,7 @@ export class ResourceTreeAdapter implements ReactAdapter { }; this.myNotebooksContentRoot = { - name: "My Notebooks", + name: ResourceTreeAdapter.MyNotebooksTitle, path: this.container.getNotebookBasePath(), type: NotebookContentItemType.Directory }; @@ -118,7 +122,7 @@ export class ResourceTreeAdapter implements ReactAdapter { if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { this.gitHubNotebooksContentRoot = { - name: "GitHub repos", + name: ResourceTreeAdapter.GitHubReposTitle, path: ResourceTreeAdapter.PseudoDirPath, type: NotebookContentItemType.Directory }; @@ -563,6 +567,11 @@ export class ResourceTreeAdapter implements ReactAdapter { ); } }, + { + label: "Copy to ...", + iconSrc: CopyIcon, + onClick: () => this.copyNotebook(item) + }, { label: "Download", iconSrc: NotebookIcon, @@ -574,6 +583,13 @@ export class ResourceTreeAdapter implements ReactAdapter { }; } + private copyNotebook = async (item: NotebookContentItem) => { + const content = await this.container.readFile(item); + if (content) { + this.container.copyNotebook(item.name, content); + } + }; + private createDirectoryContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] { let items: TreeNodeMenuItem[] = [ { diff --git a/src/GitHub/GitHubClient.ts b/src/GitHub/GitHubClient.ts index f6d761405..64ed9e2d1 100644 --- a/src/GitHub/GitHubClient.ts +++ b/src/GitHub/GitHubClient.ts @@ -317,6 +317,13 @@ export class GitHubClient { objectExpression: `refs/heads/${branch}:${path || ""}` } as ContentsQueryParams)) as ContentsQueryResponse; + if (!response.repository.object) { + return { + status: HttpStatusCodes.NotFound, + data: undefined + }; + } + let data: IGitHubFile | IGitHubFile[]; const entries = response.repository.object.entries; const gitHubRepo = GitHubClient.toGitHubRepo(response.repository); diff --git a/src/GitHub/GitHubContentProvider.ts b/src/GitHub/GitHubContentProvider.ts index e4be2a11e..899268704 100644 --- a/src/GitHub/GitHubContentProvider.ts +++ b/src/GitHub/GitHubContentProvider.ts @@ -2,10 +2,11 @@ import { Notebook, stringifyNotebook, makeNotebookRecord, toJS } from "@nteract/ import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, ServerConfig } from "@nteract/core"; import { from, Observable, of } from "rxjs"; import { AjaxResponse } from "rxjs/ajax"; +import * as Base64Utils from "../Utils/Base64Utils"; import { HttpStatusCodes } from "../Common/Constants"; import * as Logger from "../Common/Logger"; import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil"; -import { GitHubClient, IGitHubFile, IGitHubResponse, IGitHubCommit, IGitHubBranch } from "./GitHubClient"; +import { GitHubClient, IGitHubFile, IGitHubResponse } from "./GitHubClient"; import * as GitHubUtils from "../Utils/GitHubUtils"; import UrlUtility from "../Common/UrlUtility"; @@ -131,7 +132,7 @@ export class GitHubContentProvider implements IContentProvider { throw new GitHubContentProviderError(`Failed to parse ${uri}`); } - const content = btoa(stringifyNotebook(toJS(makeNotebookRecord()))); + const content = Base64Utils.utf8ToB64(stringifyNotebook(toJS(makeNotebookRecord()))); const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", @@ -195,34 +196,63 @@ export class GitHubContentProvider implements IContentProvider { return from( this.getContent(uri).then(async (content: IGitHubResponse) => { try { - const commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save"); + let commitMsg: string; + if (content.status === HttpStatusCodes.NotFound) { + // We'll create a new file since it doesn't exist + commitMsg = await this.params.promptForCommitMsg("Save", "Save"); + if (!commitMsg) { + throw new GitHubContentProviderError("Couldn't get a commit message"); + } + } else { + commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save"); + } + let updatedContent: string; if (model.type === "notebook") { - updatedContent = btoa(stringifyNotebook(model.content as Notebook)); + updatedContent = Base64Utils.utf8ToB64(stringifyNotebook(model.content as Notebook)); } else if (model.type === "file") { updatedContent = model.content as string; if (model.format !== "base64") { - updatedContent = btoa(updatedContent); + updatedContent = Base64Utils.utf8ToB64(updatedContent); } } else { throw new GitHubContentProviderError("Unsupported content type"); } - const gitHubFile = content.data as IGitHubFile; - const response = await this.params.gitHubClient.createOrUpdateFileAsync( - gitHubFile.repo.owner, - gitHubFile.repo.name, - gitHubFile.branch.name, - gitHubFile.path, - commitMsg, - updatedContent, - gitHubFile.sha - ); - if (response.status !== HttpStatusCodes.OK) { - throw new GitHubContentProviderError("Failed to update", response.status); + const contentInfo = GitHubUtils.fromContentUri(uri); + let gitHubFile: IGitHubFile; + if (content.data) { + gitHubFile = content.data as IGitHubFile; } - gitHubFile.commit = response.data; + const response = await this.params.gitHubClient.createOrUpdateFileAsync( + contentInfo.owner, + contentInfo.repo, + contentInfo.branch, + contentInfo.path, + commitMsg, + updatedContent, + gitHubFile?.sha + ); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.Created) { + throw new GitHubContentProviderError("Failed to create or update", response.status); + } + + if (gitHubFile) { + gitHubFile.commit = response.data; + } else { + const contentResponse = await this.params.gitHubClient.getContentsAsync( + contentInfo.owner, + contentInfo.repo, + contentInfo.branch, + contentInfo.path + ); + if (contentResponse.status !== HttpStatusCodes.OK) { + throw new GitHubContentProviderError("Failed to get content", response.status); + } + + gitHubFile = contentResponse.data as IGitHubFile; + } return this.createSuccessAjaxResponse( HttpStatusCodes.OK, diff --git a/src/Utils/Base64Utils.test.ts b/src/Utils/Base64Utils.test.ts new file mode 100644 index 000000000..938fe3f46 --- /dev/null +++ b/src/Utils/Base64Utils.test.ts @@ -0,0 +1,11 @@ +import * as Base64Utils from "./Base64Utils"; + +describe("Base64Utils", () => { + describe("utf8ToB64", () => { + it("should convert utf8 to base64", () => { + expect(Base64Utils.utf8ToB64("abcd")).toEqual(btoa("abcd")); + expect(Base64Utils.utf8ToB64("小飼弾")).toEqual("5bCP6aO85by+"); + expect(Base64Utils.utf8ToB64("à mon hôpital préféré")).toEqual("w6AgbW9uIGjDtHBpdGFsIHByw6lmw6lyw6k="); + }); + }); +}); diff --git a/src/Utils/Base64Utils.ts b/src/Utils/Base64Utils.ts new file mode 100644 index 000000000..83396f0b9 --- /dev/null +++ b/src/Utils/Base64Utils.ts @@ -0,0 +1,7 @@ +export const utf8ToB64 = (utf8Str: string): string => { + return btoa( + encodeURIComponent(utf8Str).replace(/%([0-9A-F]{2})/g, (_, args) => { + return String.fromCharCode(parseInt(args, 16)); + }) + ); +}; diff --git a/src/explorer.html b/src/explorer.html index fd6ae83c4..263e6ab98 100644 --- a/src/explorer.html +++ b/src/explorer.html @@ -296,6 +296,10 @@
+ +
+ +
Date: Tue, 11 Aug 2020 18:36:42 -0700 Subject: [PATCH 10/13] Fix deleteDatabase and deleteCollection with ARM (#143) --- .../dataAccess/deleteCollection.test.ts | 4 ++- src/Common/dataAccess/deleteCollection.ts | 35 +++++++++++++++---- src/Common/dataAccess/deleteDatabase.test.ts | 4 ++- src/Common/dataAccess/deleteDatabase.ts | 31 ++++++++++++---- src/DefaultAccountExperienceType.ts | 8 +++++ src/Explorer/Explorer.ts | 8 ++++- src/Shared/DefaultExperienceUtility.ts | 20 +++++++++++ src/UserContext.ts | 2 ++ 8 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 src/DefaultAccountExperienceType.ts diff --git a/src/Common/dataAccess/deleteCollection.test.ts b/src/Common/dataAccess/deleteCollection.test.ts index ad24e2920..265a1f299 100644 --- a/src/Common/dataAccess/deleteCollection.test.ts +++ b/src/Common/dataAccess/deleteCollection.test.ts @@ -6,6 +6,7 @@ import { AuthType } from "../../AuthType"; import { updateUserContext } from "../../UserContext"; import { DatabaseAccount } from "../../Contracts/DataModels"; import { sendCachedDataMessage } from "../MessageHandler"; +import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType"; describe("deleteCollection", () => { it("should call ARM if logged in with AAD", async () => { @@ -13,7 +14,8 @@ describe("deleteCollection", () => { updateUserContext({ databaseAccount: { name: "test" - } as DatabaseAccount + } as DatabaseAccount, + defaultExperience: DefaultAccountExperienceType.DocumentDB }); (sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined); await deleteCollection("database", "collection"); diff --git a/src/Common/dataAccess/deleteCollection.ts b/src/Common/dataAccess/deleteCollection.ts index 79362527a..c8975d767 100644 --- a/src/Common/dataAccess/deleteCollection.ts +++ b/src/Common/dataAccess/deleteCollection.ts @@ -1,5 +1,10 @@ import { AuthType } from "../../AuthType"; +import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType"; import { deleteSqlContainer } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources"; +import { deleteCassandraTable } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources"; +import { deleteMongoDBCollection } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources"; +import { deleteGremlinGraph } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources"; +import { deleteTable } from "../../Utils/arm/generatedClients/2020-04-01/tableResources"; import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { userContext } from "../../UserContext"; import { client } from "../CosmosClient"; @@ -9,13 +14,7 @@ export async function deleteCollection(databaseId: string, collectionId: string) const clearMessage = logConsoleProgress(`Deleting container ${collectionId}`); try { if (window.authType === AuthType.AAD) { - await deleteSqlContainer( - userContext.subscriptionId, - userContext.resourceGroup, - userContext.databaseAccount.name, - databaseId, - collectionId - ); + await deleteCollectionWithARM(databaseId, collectionId); } else { await client() .database(databaseId) @@ -30,3 +29,25 @@ export async function deleteCollection(databaseId: string, collectionId: string) clearMessage(); await refreshCachedResources(); } + +function deleteCollectionWithARM(databaseId: string, collectionId: string): Promise { + const subscriptionId = userContext.subscriptionId; + const resourceGroup = userContext.resourceGroup; + const accountName = userContext.databaseAccount.name; + const defaultExperience = userContext.defaultExperience; + + switch (defaultExperience) { + case DefaultAccountExperienceType.DocumentDB: + return deleteSqlContainer(subscriptionId, resourceGroup, accountName, databaseId, collectionId); + case DefaultAccountExperienceType.MongoDB: + return deleteMongoDBCollection(subscriptionId, resourceGroup, accountName, databaseId, collectionId); + case DefaultAccountExperienceType.Cassandra: + return deleteCassandraTable(subscriptionId, resourceGroup, accountName, databaseId, collectionId); + case DefaultAccountExperienceType.Graph: + return deleteGremlinGraph(subscriptionId, resourceGroup, accountName, databaseId, collectionId); + case DefaultAccountExperienceType.Table: + return deleteTable(subscriptionId, resourceGroup, accountName, collectionId); + default: + throw new Error(`Unsupported default experience type: ${defaultExperience}`); + } +} diff --git a/src/Common/dataAccess/deleteDatabase.test.ts b/src/Common/dataAccess/deleteDatabase.test.ts index f3a7146a7..2cd898ba6 100644 --- a/src/Common/dataAccess/deleteDatabase.test.ts +++ b/src/Common/dataAccess/deleteDatabase.test.ts @@ -6,6 +6,7 @@ import { AuthType } from "../../AuthType"; import { updateUserContext } from "../../UserContext"; import { DatabaseAccount } from "../../Contracts/DataModels"; import { sendCachedDataMessage } from "../MessageHandler"; +import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType"; describe("deleteDatabase", () => { it("should call ARM if logged in with AAD", async () => { @@ -13,7 +14,8 @@ describe("deleteDatabase", () => { updateUserContext({ databaseAccount: { name: "test" - } as DatabaseAccount + } as DatabaseAccount, + defaultExperience: DefaultAccountExperienceType.DocumentDB }); (sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined); await deleteDatabase("database"); diff --git a/src/Common/dataAccess/deleteDatabase.ts b/src/Common/dataAccess/deleteDatabase.ts index 12733bf53..a44a2d5bb 100644 --- a/src/Common/dataAccess/deleteDatabase.ts +++ b/src/Common/dataAccess/deleteDatabase.ts @@ -1,5 +1,9 @@ import { AuthType } from "../../AuthType"; +import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType"; import { deleteSqlDatabase } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources"; +import { deleteCassandraKeyspace } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources"; +import { deleteMongoDBDatabase } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources"; +import { deleteGremlinDatabase } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources"; import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { userContext } from "../../UserContext"; import { client } from "../CosmosClient"; @@ -12,12 +16,7 @@ export async function deleteDatabase(databaseId: string): Promise { try { if (window.authType === AuthType.AAD) { - await deleteSqlDatabase( - userContext.subscriptionId, - userContext.resourceGroup, - userContext.databaseAccount.name, - databaseId - ); + await deleteDatabaseWithARM(databaseId); } else { await client() .database(databaseId) @@ -33,3 +32,23 @@ export async function deleteDatabase(databaseId: string): Promise { clearMessage(); await refreshCachedResources(); } + +function deleteDatabaseWithARM(databaseId: string): Promise { + const subscriptionId = userContext.subscriptionId; + const resourceGroup = userContext.resourceGroup; + const accountName = userContext.databaseAccount.name; + const defaultExperience = userContext.defaultExperience; + + switch (defaultExperience) { + case DefaultAccountExperienceType.DocumentDB: + return deleteSqlDatabase(subscriptionId, resourceGroup, accountName, databaseId); + case DefaultAccountExperienceType.MongoDB: + return deleteMongoDBDatabase(subscriptionId, resourceGroup, accountName, databaseId); + case DefaultAccountExperienceType.Cassandra: + return deleteCassandraKeyspace(subscriptionId, resourceGroup, accountName, databaseId); + case DefaultAccountExperienceType.Graph: + return deleteGremlinDatabase(subscriptionId, resourceGroup, accountName, databaseId); + default: + throw new Error(`Unsupported default experience type: ${defaultExperience}`); + } +} diff --git a/src/DefaultAccountExperienceType.ts b/src/DefaultAccountExperienceType.ts new file mode 100644 index 000000000..a7cd708aa --- /dev/null +++ b/src/DefaultAccountExperienceType.ts @@ -0,0 +1,8 @@ +export enum DefaultAccountExperienceType { + DocumentDB = "DocumentDB", + Graph = "Graph", + MongoDB = "MongoDB", + Table = "Table", + Cassandra = "Cassandra", + ApiForMongoDB = "Azure Cosmos DB for MongoDB API" +} diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 15be7c0c9..81a5d365c 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -482,7 +482,13 @@ export default class Explorer { this.notificationConsoleData = ko.observableArray([]); this.defaultExperience = ko.observable(); this.databaseAccount.subscribe(databaseAccount => { - this.defaultExperience(DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount(databaseAccount)); + const defaultExperience: string = DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount( + databaseAccount + ); + this.defaultExperience(defaultExperience); + updateUserContext({ + defaultExperience: DefaultExperienceUtility.mapDefaultExperienceStringToEnum(defaultExperience) + }); }); this.isPreferredApiDocumentDB = ko.computed(() => { diff --git a/src/Shared/DefaultExperienceUtility.ts b/src/Shared/DefaultExperienceUtility.ts index f3837a7d8..81550466e 100644 --- a/src/Shared/DefaultExperienceUtility.ts +++ b/src/Shared/DefaultExperienceUtility.ts @@ -1,6 +1,7 @@ import * as _ from "underscore"; import * as Constants from "../Common/Constants"; import * as DataModels from "../Contracts/DataModels"; +import { DefaultAccountExperienceType } from "../DefaultAccountExperienceType"; export class DefaultExperienceUtility { public static getDefaultExperienceFromDatabaseAccount(databaseAccount: DataModels.DatabaseAccount): string { @@ -59,6 +60,25 @@ export class DefaultExperienceUtility { } } + public static mapDefaultExperienceStringToEnum(defaultExperience: string): DefaultAccountExperienceType { + switch (defaultExperience) { + case Constants.DefaultAccountExperience.DocumentDB: + return DefaultAccountExperienceType.DocumentDB; + case Constants.DefaultAccountExperience.Graph: + return DefaultAccountExperienceType.Graph; + case Constants.DefaultAccountExperience.MongoDB: + return DefaultAccountExperienceType.MongoDB; + case Constants.DefaultAccountExperience.Table: + return DefaultAccountExperienceType.Table; + case Constants.DefaultAccountExperience.Cassandra: + return DefaultAccountExperienceType.Cassandra; + case Constants.DefaultAccountExperience.ApiForMongoDB: + return DefaultAccountExperienceType.ApiForMongoDB; + default: + throw new Error(`Unsupported default experience type: ${defaultExperience}`); + } + } + private static _getDefaultExperience(kind: string, capabilities: DataModels.Capability[]): string { const defaultDefaultExperience: string = Constants.DefaultAccountExperience.DocumentDB; const defaultExperienceFromKind: string = DefaultExperienceUtility._getDefaultExperienceFromAccountKind(kind); diff --git a/src/UserContext.ts b/src/UserContext.ts index dd4a851b4..e9b4f6e62 100644 --- a/src/UserContext.ts +++ b/src/UserContext.ts @@ -1,4 +1,5 @@ import { DatabaseAccount } from "./Contracts/DataModels"; +import { DefaultAccountExperienceType } from "./DefaultAccountExperienceType"; interface UserContext { masterKey?: string; @@ -9,6 +10,7 @@ interface UserContext { accessToken?: string; authorizationToken?: string; resourceToken?: string; + defaultExperience?: DefaultAccountExperienceType; } const userContext: Readonly = {} as const; From fb71fb4e827c901f1ec90161b9ee9dcd0cea360a Mon Sep 17 00:00:00 2001 From: victor-meng <56978073+victor-meng@users.noreply.github.com> Date: Wed, 12 Aug 2020 11:41:19 -0700 Subject: [PATCH 11/13] Refactor GenericRightPaneComponent to accept a ReactComponent as its content (#146) --- src/Explorer/Panes/CopyNotebookPane.tsx | 113 +++-------------- .../Panes/CopyNotebookPaneComponent.tsx | 119 ++++++++++++++++++ .../Panes/GenericRightPaneComponent.tsx | 19 ++- .../Panes/PublishNotebookPaneAdapter.tsx | 67 +++++----- src/Explorer/Panes/UploadItemsPaneAdapter.tsx | 109 +++------------- .../Panes/UploadItemsPaneComponent.tsx | 97 ++++++++++++++ 6 files changed, 288 insertions(+), 236 deletions(-) create mode 100644 src/Explorer/Panes/CopyNotebookPaneComponent.tsx create mode 100644 src/Explorer/Panes/UploadItemsPaneComponent.tsx diff --git a/src/Explorer/Panes/CopyNotebookPane.tsx b/src/Explorer/Panes/CopyNotebookPane.tsx index 62ad1c6c7..586af6318 100644 --- a/src/Explorer/Panes/CopyNotebookPane.tsx +++ b/src/Explorer/Panes/CopyNotebookPane.tsx @@ -6,17 +6,8 @@ import { JunoClient, IPinnedRepo } from "../../Juno/JunoClient"; import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"; import Explorer from "../Explorer"; import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent"; -import { - Stack, - Label, - Text, - Dropdown, - IDropdownProps, - IDropdownOption, - SelectableOptionMenuItemType, - IRenderFunction, - ISelectableOption -} from "office-ui-fabric-react"; +import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent"; +import { IDropdownOption } from "office-ui-fabric-react"; import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService"; import { HttpStatusCodes } from "../../Common/Constants"; import * as GitHubUtils from "../../Utils/GitHubUtils"; @@ -60,9 +51,8 @@ export class CopyNotebookPaneAdapter implements ReactAdapter { return undefined; } - const props: GenericRightPaneProps = { + const genericPaneProps: GenericRightPaneProps = { container: this.container, - content: this.createContent(), formError: this.formError, formErrorDetail: this.formErrorDetail, id: "copynotebookpane", @@ -73,7 +63,17 @@ export class CopyNotebookPaneAdapter implements ReactAdapter { onSubmit: () => this.submit() }; - return ; + const copyNotebookPaneProps: CopyNotebookPaneProps = { + name: this.name, + pinnedRepos: this.pinnedRepos, + onDropDownChange: this.onDropDownChange + }; + + return ( + + + + ); } public triggerRender(): void { @@ -181,91 +181,6 @@ export class CopyNotebookPaneAdapter implements ReactAdapter { return this.container.uploadFile(this.name, this.content, parent); }; - private createContent = (): JSX.Element => { - const dropDownProps: IDropdownProps = { - label: "Location", - ariaLabel: "Location", - placeholder: "Select an option", - onRenderTitle: this.onRenderDropDownTitle, - onRenderOption: this.onRenderDropDownOption, - options: this.getDropDownOptions(), - onChange: this.onDropDownChange - }; - - return ( -
- - - - {this.name} - - - - -
- ); - }; - - private onRenderDropDownTitle: IRenderFunction = (options: IDropdownOption[]): JSX.Element => { - return {options.length && options[0].title}; - }; - - private onRenderDropDownOption: IRenderFunction = (option: ISelectableOption): JSX.Element => { - return {option.text}; - }; - - private getDropDownOptions = (): IDropdownOption[] => { - const options: IDropdownOption[] = []; - - options.push({ - key: "MyNotebooks-Item", - text: ResourceTreeAdapter.MyNotebooksTitle, - title: ResourceTreeAdapter.MyNotebooksTitle, - data: { - type: "MyNotebooks" - } as Location - }); - - if (this.pinnedRepos && this.pinnedRepos.length > 0) { - options.push({ - key: "GitHub-Header-Divider", - text: undefined, - itemType: SelectableOptionMenuItemType.Divider - }); - - options.push({ - key: "GitHub-Header", - text: ResourceTreeAdapter.GitHubReposTitle, - itemType: SelectableOptionMenuItemType.Header - }); - - this.pinnedRepos.forEach(pinnedRepo => { - const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name); - options.push({ - key: `GitHub-Repo-${repoFullName}`, - text: repoFullName, - disabled: true - }); - - pinnedRepo.branches.forEach(branch => - options.push({ - key: `GitHub-Repo-${repoFullName}-${branch.name}`, - text: `${CopyNotebookPaneAdapter.BranchNameWhiteSpace}${branch.name}`, - title: `${repoFullName} - ${branch.name}`, - data: { - type: "GitHub", - owner: pinnedRepo.owner, - repo: pinnedRepo.name, - branch: branch.name - } as Location - }) - ); - }); - } - - return options; - }; - private onDropDownChange = (_: React.FormEvent, option?: IDropdownOption): void => { this.selectedLocation = option?.data; }; diff --git a/src/Explorer/Panes/CopyNotebookPaneComponent.tsx b/src/Explorer/Panes/CopyNotebookPaneComponent.tsx new file mode 100644 index 000000000..da3e87958 --- /dev/null +++ b/src/Explorer/Panes/CopyNotebookPaneComponent.tsx @@ -0,0 +1,119 @@ +import * as GitHubUtils from "../../Utils/GitHubUtils"; +import * as React from "react"; +import { IPinnedRepo } from "../../Juno/JunoClient"; +import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; +import { + Stack, + Label, + Text, + Dropdown, + IDropdownProps, + IDropdownOption, + SelectableOptionMenuItemType, + IRenderFunction, + ISelectableOption +} from "office-ui-fabric-react"; + +interface Location { + type: "MyNotebooks" | "GitHub"; + + // GitHub + owner?: string; + repo?: string; + branch?: string; +} + +export interface CopyNotebookPaneProps { + name: string; + pinnedRepos: IPinnedRepo[]; + onDropDownChange: (_: React.FormEvent, option?: IDropdownOption) => void; +} + +export class CopyNotebookPaneComponent extends React.Component { + private static readonly BranchNameWhiteSpace = " "; + + public render(): JSX.Element { + const dropDownProps: IDropdownProps = { + label: "Location", + ariaLabel: "Location", + placeholder: "Select an option", + onRenderTitle: this.onRenderDropDownTitle, + onRenderOption: this.onRenderDropDownOption, + options: this.getDropDownOptions(), + onChange: this.props.onDropDownChange + }; + + return ( +
+ + + + {this.props.name} + + + + +
+ ); + } + + private onRenderDropDownTitle: IRenderFunction = (options: IDropdownOption[]): JSX.Element => { + return {options.length && options[0].title}; + }; + + private onRenderDropDownOption: IRenderFunction = (option: ISelectableOption): JSX.Element => { + return {option.text}; + }; + + private getDropDownOptions = (): IDropdownOption[] => { + const options: IDropdownOption[] = []; + + options.push({ + key: "MyNotebooks-Item", + text: ResourceTreeAdapter.MyNotebooksTitle, + title: ResourceTreeAdapter.MyNotebooksTitle, + data: { + type: "MyNotebooks" + } as Location + }); + + if (this.props.pinnedRepos && this.props.pinnedRepos.length > 0) { + options.push({ + key: "GitHub-Header-Divider", + text: undefined, + itemType: SelectableOptionMenuItemType.Divider + }); + + options.push({ + key: "GitHub-Header", + text: ResourceTreeAdapter.GitHubReposTitle, + itemType: SelectableOptionMenuItemType.Header + }); + + this.props.pinnedRepos.forEach(pinnedRepo => { + const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name); + options.push({ + key: `GitHub-Repo-${repoFullName}`, + text: repoFullName, + disabled: true + }); + + pinnedRepo.branches.forEach(branch => + options.push({ + key: `GitHub-Repo-${repoFullName}-${branch.name}`, + text: `${CopyNotebookPaneComponent.BranchNameWhiteSpace}${branch.name}`, + title: `${repoFullName} - ${branch.name}`, + data: { + type: "GitHub", + owner: pinnedRepo.owner, + repo: pinnedRepo.name, + branch: branch.name + } as Location + }) + ); + }); + } + + return options; + }; +} diff --git a/src/Explorer/Panes/GenericRightPaneComponent.tsx b/src/Explorer/Panes/GenericRightPaneComponent.tsx index f4e4d2285..30f278349 100644 --- a/src/Explorer/Panes/GenericRightPaneComponent.tsx +++ b/src/Explorer/Panes/GenericRightPaneComponent.tsx @@ -8,7 +8,6 @@ import Explorer from "../Explorer"; export interface GenericRightPaneProps { container: Explorer; - content: JSX.Element; formError: string; formErrorDetail: string; id: string; @@ -57,18 +56,18 @@ export class GenericRightPaneComponent extends React.Component
- {this.createPanelHeader()} - {this.createErrorSection()} - {this.props.content} - {this.createPanelFooter()} + {this.renderPanelHeader()} + {this.renderErrorSection()} + {this.props.children} + {this.renderPanelFooter()}
- {this.createLoadingScreen()} + {this.renderLoadingScreen()}
); } - private createPanelHeader = (): JSX.Element => { + private renderPanelHeader = (): JSX.Element => { return (
{this.props.title} @@ -84,7 +83,7 @@ export class GenericRightPaneComponent extends React.Component { + private renderErrorSection = (): JSX.Element => { return (