From 7512b3c1d5a30653c2c590758c296360b4304769 Mon Sep 17 00:00:00 2001 From: Tanuj Mittal Date: Tue, 30 Jun 2020 11:47:21 -0700 Subject: [PATCH] Notebooks Gallery (#59) * Initial commit * Address PR comments * Move notebook related stuff to NotebookManager and dynamically load it * Add New gallery callout and other UI tweaks * Update test snapshot --- src/Common/Constants.ts | 1 + src/Contracts/DataModels.ts | 43 - src/Contracts/ViewModels.ts | 41 +- .../CommandButton/CommandButtonComponent.tsx | 9 +- .../FeaturePanel/FeaturePanelComponent.tsx | 1 + .../FeaturePanelComponent.test.tsx.snap | 10 +- .../Cards/GalleryCardComponent.test.tsx | 29 +- .../Cards/GalleryCardComponent.tsx | 212 ++++- .../GalleryCardComponent.test.tsx.snap | 245 +++++- .../GalleryViewerComponent.test.tsx | 65 +- .../GalleryViewerComponent.tsx | 802 +++++++++++------- .../GalleryViewerComponent.test.tsx.snap | 128 ++- .../NotebookMetadataComponent.test.tsx | 56 +- .../NotebookMetadataComponent.tsx | 240 ++---- .../NotebookViewerComponent.less | 2 +- .../NotebookViewerComponent.tsx | 166 +++- .../NotebookMetadataComponent.test.tsx.snap | 202 ++++- src/Explorer/Explorer.ts | 256 ++---- .../CommandBarComponentButtonFactory.test.ts | 10 +- .../CommandBarComponentButtonFactory.ts | 22 +- .../Menus/CommandBar/CommandBarUtil.tsx | 7 +- .../NotebookComponentBootstrapper.tsx | 24 +- src/Explorer/Notebook/NotebookManager.ts | 168 ++++ src/Explorer/OpenActionsStubs.ts | 18 +- src/Explorer/Panes/GitHubReposPane.ts | 9 +- .../Panes/PublishNotebookPaneAdapter.tsx | 164 ++++ src/Explorer/Tabs/GalleryTab.tsx | 140 ++- src/Explorer/Tabs/NotebookV2Tab.ts | 30 +- src/Explorer/Tabs/NotebookViewerTab.tsx | 52 +- src/Explorer/Tree/ResourceTreeAdapter.tsx | 147 +++- src/GalleryViewer/GalleryViewer.tsx | 36 +- src/GalleryViewer/galleryViewer.html | 2 +- src/GitHub/GitHubOAuthService.test.ts | 5 +- src/GitHub/GitHubOAuthService.ts | 2 +- src/Juno/JunoClient.ts | 251 +++++- src/NotebookViewer/NotebookViewer.tsx | 64 +- src/Shared/StorageUtility.ts | 3 +- src/Utils/GalleryUtils.ts | 260 ++++++ src/Utils/JunoUtils.ts | 51 +- src/Utils/UserUtils.ts | 17 + src/explorer.html | 4 + 41 files changed, 2801 insertions(+), 1193 deletions(-) create mode 100644 src/Explorer/Notebook/NotebookManager.ts create mode 100644 src/Explorer/Panes/PublishNotebookPaneAdapter.tsx create mode 100644 src/Utils/GalleryUtils.ts create mode 100644 src/Utils/UserUtils.ts diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index a962e5ec8..2dd7327ca 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -112,6 +112,7 @@ export class Features { public static readonly enableTtl = "enablettl"; public static readonly enableNotebooks = "enablenotebooks"; public static readonly enableGallery = "enablegallery"; + public static readonly enableGalleryPublish = "enablegallerypublish"; public static readonly enableSpark = "enablespark"; public static readonly livyEndpoint = "livyendpoint"; public static readonly notebookServerUrl = "notebookserverurl"; diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 4b15a822b..c03ec0a2d 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -704,49 +704,6 @@ export interface MemoryUsageInfo { totalKB: number; } -export interface NotebookMetadata { - date: string; - description: string; - tags: string[]; - author: string; - views: number; - likes: number; - downloads: number; - imageUrl: string; -} - -export interface UserMetadata { - likedNotebooks: string[]; -} - -export interface GitHubInfoJunoResponse { - encoding: string; - encodedContent: string; - content: string; - target: string; - submoduleGitUrl: string; - name: string; - path: string; - sha: string; - size: number; - type: { - stringValue: string; - value: number; - }; - downloadUrl: string; - url: string; - gitUrl: string; - htmlUrl: string; - metadata?: NotebookMetadata; - officialSamplesIndex?: number; - isLikedNotebook?: boolean; -} - -export interface LikedNotebooksJunoResponse { - likedNotebooksContent: GitHubInfoJunoResponse[]; - userMetadata: UserMetadata; -} - export interface resourceTokenConnectionStringProperties { accountEndpoint: string; collectionId: string; diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 46989b016..499e5e7fa 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -12,10 +12,8 @@ import { CommandButtonComponentProps } from "../Explorer/Controls/CommandButton/ import { ConsoleData } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; import { ExecuteSprocParam } from "../Explorer/Panes/ExecuteSprocParamsPane"; import { GitHubClient } from "../GitHub/GitHubClient"; -import { GitHubOAuthService } from "../GitHub/GitHubOAuthService"; import { IColumnSetting } from "../Explorer/Panes/Tables/TableColumnOptionsPane"; -import { IContentProvider } from "@nteract/core"; -import { JunoClient } from "../Juno/JunoClient"; +import { JunoClient, IGalleryItem } from "../Juno/JunoClient"; import { Library } from "./DataModels"; import { MostRecentActivity } from "../Explorer/MostRecentActivity/MostRecentActivity"; import { NotebookContentItem } from "../Explorer/Notebook/NotebookContentItem"; @@ -27,6 +25,7 @@ import { StringInputPane } from "../Explorer/Panes/StringInputPane"; import { TextFieldProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent"; import { UploadDetails } from "../workers/upload/definitions"; import { UploadItemsPaneAdapter } from "../Explorer/Panes/UploadItemsPaneAdapter"; +import { ReactAdapter } from "../Bindings/ReactBindingHandler"; export interface ExplorerOptions { documentClientUtility: DocumentClientUtilityBase; @@ -85,7 +84,9 @@ export interface Explorer { armEndpoint: ko.Observable; isFeatureEnabled: (feature: string) => boolean; isGalleryEnabled: ko.Computed; + isGalleryPublishEnabled: ko.Computed; isGitHubPaneEnabled: ko.Observable; + isPublishNotebookPaneEnabled: ko.Observable; isRightPanelV2Enabled: ko.Computed; canExceedMaximumValue: ko.Computed; hasAutoPilotV2FeatureFlag: ko.Computed; @@ -153,6 +154,7 @@ export interface Explorer { libraryManagePane: ContextualPane; clusterLibraryPane: ContextualPane; gitHubReposPane: ContextualPane; + publishNotebookPaneAdapter: ReactAdapter; // Facade logConsoleData(data: ConsoleData): void; @@ -224,22 +226,17 @@ export interface Explorer { arcadiaWorkspaces: ko.ObservableArray; isNotebookTabActive: ko.Computed; memoryUsageInfo: ko.Observable; + notebookManager?: any; // This is dynamically loaded openNotebook(notebookContentItem: NotebookContentItem): Promise; // True if it was opened, false otherwise resetNotebookWorkspace(): void; importAndOpen: (path: string) => Promise; - importAndOpenFromGallery: (path: string, newName: string, content: any) => Promise; + importAndOpenFromGallery: (name: string, content: string) => Promise; + publishNotebook: (name: string, content: string) => void; openNotebookTerminal: (kind: TerminalKind) => void; - openGallery: () => void; - openNotebookViewer: ( - notebookUrl: string, - notebookMetadata: DataModels.NotebookMetadata, - onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise, - isLikedNotebook: boolean - ) => void; + openGallery: (notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) => void; + openNotebookViewer: (notebookUrl: string) => void; notebookWorkspaceManager: NotebookWorkspaceManager; sparkClusterManager: SparkClusterManager; - notebookContentProvider: IContentProvider; - gitHubOAuthService: GitHubOAuthService; mostRecentActivity: MostRecentActivity; initNotebooks: (databaseAccount: DataModels.DatabaseAccount) => Promise; deleteCluster(): void; @@ -594,6 +591,16 @@ export interface GitHubReposPaneOptions extends PaneOptions { junoClient: JunoClient; } +export interface PublishNotebookPaneOptions extends PaneOptions { + junoClient: JunoClient; +} + +export interface PublishNotebookPaneOpenOptions { + name: string; + author: string; + content: string; +} + export interface AddCollectionPaneOptions extends PaneOptions { isPreferredApiTable: ko.Computed; databaseId?: string; @@ -873,16 +880,16 @@ export interface TerminalTabOptions extends TabOptions { export interface GalleryTabOptions extends TabOptions { account: DatabaseAccount; container: Explorer; + junoClient: JunoClient; + notebookUrl?: string; + galleryItem?: IGalleryItem; + isFavorite?: boolean; } export interface NotebookViewerTabOptions extends TabOptions { account: DatabaseAccount; container: Explorer; notebookUrl: string; - notebookName: string; - notebookMetadata: DataModels.NotebookMetadata; - onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise; - isLikedNotebook: boolean; } export interface DocumentsTabOptions extends TabOptions { diff --git a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx index 2ccafc278..98d4b4c0d 100644 --- a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx +++ b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx @@ -15,15 +15,20 @@ import { ArcadiaMenuPickerProps } from "../Arcadia/ArcadiaMenuPicker"; * Options for this component */ export interface CommandButtonComponentProps { + /** + * font icon name for the button + */ + iconName?: string; + /** * image source for the button icon */ - iconSrc: string; + iconSrc?: string; /** * image alt for accessibility */ - iconAlt: string; + iconAlt?: string; /** * Click handler for command button click diff --git a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx index dfc33a85a..b9b72a555 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.enablegallery", label: "Enable Notebook Gallery", value: "true" }, + { key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", 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 3b1c08f62..1afcd42fb 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" > + { it("renders", () => { const props: GalleryCardComponentProps = { - name: "mycard", - url: "url", - notebookMetadata: undefined, - // eslint-disable-next-line @typescript-eslint/no-empty-function - onClick: () => {} + data: { + id: "id", + name: "name", + description: "description", + author: "author", + thumbnailUrl: "thumbnailUrl", + created: "created", + gitSha: "gitSha", + tags: ["tag"], + isSample: false, + downloads: 0, + favorites: 0, + views: 0 + }, + isFavorite: false, + showDelete: true, + onClick: undefined, + onTagClick: undefined, + onFavoriteClick: undefined, + onUnfavoriteClick: undefined, + onDownloadClick: undefined, + onDeleteClick: undefined }; const wrapper = shallow(); diff --git a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx index 03d225bd5..4859aa748 100644 --- a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx @@ -1,65 +1,199 @@ -import * as React from "react"; -import * as DataModels from "../../../../Contracts/DataModels"; -import { Card, ICardTokens, ICardSectionTokens } from "@uifabric/react-cards"; -import { Icon, Image, Persona, Text } from "office-ui-fabric-react"; +import { Card, ICardTokens } from "@uifabric/react-cards"; import { - siteTextStyles, - descriptionTextStyles, - helpfulTextStyles, - subtleHelpfulTextStyles, - subtleIconStyles -} from "./CardStyleConstants"; + FontWeights, + Icon, + IconButton, + Image, + ImageFit, + Persona, + Text, + Link, + BaseButton, + Button, + LinkBase, + Separator, + TooltipHost +} from "office-ui-fabric-react"; +import * as React from "react"; +import { IGalleryItem } from "../../../../Juno/JunoClient"; +import { FileSystemUtil } from "../../../Notebook/FileSystemUtil"; export interface GalleryCardComponentProps { - name: string; - url: string; - notebookMetadata: DataModels.NotebookMetadata; + data: IGalleryItem; + isFavorite: boolean; + showDelete: boolean; onClick: () => void; + onTagClick: (tag: string) => void; + onFavoriteClick: () => void; + onUnfavoriteClick: () => void; + onDownloadClick: () => void; + onDeleteClick: () => void; } export class GalleryCardComponent extends React.Component { - private cardTokens: ICardTokens = { childrenMargin: 12 }; - private attendantsCardSectionTokens: ICardSectionTokens = { childrenGap: 6 }; + public static readonly CARD_HEIGHT = 384; + public static readonly CARD_WIDTH = 256; + + private static readonly cardImageHeight = 144; + private static readonly cardDescriptionMaxChars = 88; + private static readonly cardTokens: ICardTokens = { + width: GalleryCardComponent.CARD_WIDTH, + height: GalleryCardComponent.CARD_HEIGHT, + childrenGap: 8, + childrenMargin: 10 + }; public render(): JSX.Element { - return this.props.notebookMetadata !== undefined ? ( - + const options: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric" + }; + + const dateString = new Date(this.props.data.created).toLocaleString("default", options); + + return ( + - + + - Notebook display image + Notebook cover image + - - {this.props.notebookMetadata.tags.join(", ")} + + {this.props.data.tags?.map((tag, index, array) => ( + + this.onTagClick(event, tag)}>{tag} + {index === array.length - 1 ? <> : ", "} + + ))} - {this.props.name} - - {this.props.notebookMetadata.description} + + {FileSystemUtil.stripExtension(this.props.data.name, "ipynb")} + + + {this.props.data.description.substr(0, GalleryCardComponent.cardDescriptionMaxChars)} - - - - {this.props.notebookMetadata.views} + + + + {this.props.data.views} - - - {this.props.notebookMetadata.downloads} + + {this.props.data.downloads} - - - {this.props.notebookMetadata.likes} + + {this.props.data.favorites} - - ) : ( - - - {this.props.name} + + + + + + + {this.generateIconButtonWithTooltip( + this.props.isFavorite ? "HeartFill" : "Heart", + this.props.isFavorite ? "Unlike" : "Like", + this.props.isFavorite ? this.onUnfavoriteClick : this.onFavoriteClick + )} + + {this.generateIconButtonWithTooltip("Download", "Download", this.onDownloadClick)} + + {this.props.showDelete && ( +
+ {this.generateIconButtonWithTooltip("Delete", "Remove", this.props.onDeleteClick)} +
+ )}
); } + + /* + * Fluent UI doesn't support tooltips on IconButtons out of the box. In the meantime the recommendation is + * to do the following (from https://developer.microsoft.com/en-us/fluentui#/controls/web/button) + */ + private generateIconButtonWithTooltip = ( + iconName: string, + title: string, + onClick: ( + event: React.MouseEvent< + HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement, + MouseEvent + > + ) => void + ): JSX.Element => { + return ( + + + + ); + }; + + private onTagClick = ( + event: React.MouseEvent, + tag: string + ): void => { + event.stopPropagation(); + this.props.onTagClick(tag); + }; + + private onFavoriteClick = ( + event: React.MouseEvent< + HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement, + MouseEvent + > + ): void => { + event.stopPropagation(); + this.props.onFavoriteClick(); + }; + + private onUnfavoriteClick = ( + event: React.MouseEvent< + HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement, + MouseEvent + > + ): void => { + event.stopPropagation(); + this.props.onUnfavoriteClick(); + }; + + private onDownloadClick = ( + event: React.MouseEvent< + HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement, + MouseEvent + > + ): void => { + event.stopPropagation(); + this.props.onDownloadClick(); + }; + + private onDeleteClick = ( + event: React.MouseEvent< + HTMLAnchorElement | HTMLButtonElement | HTMLDivElement | BaseButton | Button | HTMLSpanElement, + MouseEvent + > + ): void => { + event.stopPropagation(); + this.props.onDeleteClick(); + }; } diff --git a/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap index 7ddd466c5..22e1b2499 100644 --- a/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookGallery/Cards/__snapshots__/GalleryCardComponent.test.tsx.snap @@ -3,26 +3,263 @@ exports[`GalleryCardComponent renders 1`] = ` + + + + + + + + + tag + + + + - mycard + name + + description + + + + + + + 0 + + + + + 0 + + + + + 0 + + + + + + + + + + + + +
+ + + +
`; diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx index 8486ebbb5..452957c6f 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.test.tsx @@ -1,62 +1,17 @@ -import React from "react"; import { shallow } from "enzyme"; -import { - GalleryViewerContainerComponent, - GalleryViewerContainerComponentProps, - FullWidthTabs, - FullWidthTabsProps, - GalleryCardsComponent, - GalleryCardsComponentProps, - GalleryViewerComponent, - GalleryViewerComponentProps -} from "./GalleryViewerComponent"; +import React from "react"; +import { GalleryViewerComponent, GalleryViewerComponentProps, GalleryTab, SortBy } from "./GalleryViewerComponent"; -describe("GalleryCardsComponent", () => { - it("renders", () => { - // TODO Mock this - const props: GalleryCardsComponentProps = { - data: [], - userMetadata: undefined, - onNotebookMetadataChange: () => Promise.resolve(), - onClick: () => Promise.resolve() - }; - - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); -}); - -describe("FullWidthTabs", () => { - it("renders", () => { - const props: FullWidthTabsProps = { - officialSamplesContent: [], - likedNotebooksContent: [], - userMetadata: undefined, - onClick: () => Promise.resolve() - }; - - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); -}); - -describe("GalleryViewerContainerComponent", () => { - it("renders", () => { - const props: GalleryViewerContainerComponentProps = { - container: undefined - }; - - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); -}); - -describe("GalleryCardComponent", () => { +describe("GalleryViewerComponent", () => { it("renders", () => { const props: GalleryViewerComponentProps = { - container: undefined, - officialSamplesData: [], - likedNotebookData: undefined + junoClient: undefined, + selectedTab: GalleryTab.OfficialSamples, + sortBy: SortBy.MostViewed, + searchText: undefined, + onSelectedTabChange: undefined, + onSortByChange: undefined, + onSearchTextChange: undefined }; const wrapper = shallow(); diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx index 0e5fc747e..940c73976 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx @@ -1,361 +1,513 @@ -/** - * Gallery Viewer - */ - +import { + Dropdown, + FocusZone, + IDropdownOption, + IPageSpecification, + IPivotItemProps, + IPivotProps, + IRectangle, + Label, + List, + Pivot, + PivotItem, + SearchBox, + Stack +} from "office-ui-fabric-react"; import * as React from "react"; -import * as DataModels from "../../../Contracts/DataModels"; +import * as Logger from "../../../Common/Logger"; import * as ViewModels from "../../../Contracts/ViewModels"; -import { GalleryCardComponent } from "./Cards/GalleryCardComponent"; -import { Stack, IStackTokens } from "office-ui-fabric-react"; -import { JunoUtils } from "../../../Utils/JunoUtils"; -import { CosmosClient } from "../../../Common/CosmosClient"; -import { config } from "../../../Config"; -import path from "path"; -import { SessionStorageUtility, StorageKey } from "../../../Shared/StorageUtility"; +import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; +import * as GalleryUtils from "../../../Utils/GalleryUtils"; import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils"; import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; -import * as TabComponent from "../Tabs/TabComponent"; - +import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComponent"; +import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/GalleryCardComponent"; import "./GalleryViewerComponent.less"; +import { HttpStatusCodes } from "../../../Common/Constants"; -export interface GalleryCardsComponentProps { - data: DataModels.GitHubInfoJunoResponse[]; - userMetadata: DataModels.UserMetadata; - onNotebookMetadataChange: ( - officialSamplesIndex: number, - notebookMetadata: DataModels.NotebookMetadata - ) => Promise; - onClick: ( - url: string, - notebookMetadata: DataModels.NotebookMetadata, - onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise, - isLikedNotebook: boolean - ) => Promise; +export interface GalleryViewerComponentProps { + container?: ViewModels.Explorer; + junoClient: JunoClient; + selectedTab: GalleryTab; + sortBy: SortBy; + searchText: string; + onSelectedTabChange: (newTab: GalleryTab) => void; + onSortByChange: (sortBy: SortBy) => void; + onSearchTextChange: (searchText: string) => void; } -export class GalleryCardsComponent extends React.Component { - private sectionStackTokens: IStackTokens = { childrenGap: 30 }; +export enum GalleryTab { + OfficialSamples, + PublicGallery, + Favorites, + Published +} + +export enum SortBy { + MostViewed, + MostDownloaded, + MostFavorited, + MostRecent +} + +interface GalleryViewerComponentState { + sampleNotebooks: IGalleryItem[]; + publicNotebooks: IGalleryItem[]; + favoriteNotebooks: IGalleryItem[]; + publishedNotebooks: IGalleryItem[]; + selectedTab: GalleryTab; + sortBy: SortBy; + searchText: string; + dialogProps: DialogProps; +} + +interface GalleryTabInfo { + tab: GalleryTab; + content: JSX.Element; +} + +export class GalleryViewerComponent extends React.Component + implements GalleryUtils.DialogEnabledComponent { + public static readonly OfficialSamplesTitle = "Official samples"; + public static readonly PublicGalleryTitle = "Public gallery"; + public static readonly FavoritesTitle = "Liked"; + public static readonly PublishedTitle = "Your published work"; + + private static readonly mostViewedText = "Most viewed"; + private static readonly mostDownloadedText = "Most downloaded"; + private static readonly mostFavoritedText = "Most favorited"; + private static readonly mostRecentText = "Most recent"; + + private static readonly sortingOptions: IDropdownOption[] = [ + { + key: SortBy.MostViewed, + text: GalleryViewerComponent.mostViewedText + }, + { + key: SortBy.MostDownloaded, + text: GalleryViewerComponent.mostDownloadedText + }, + { + key: SortBy.MostFavorited, + text: GalleryViewerComponent.mostFavoritedText + }, + { + key: SortBy.MostRecent, + text: GalleryViewerComponent.mostRecentText + } + ]; + + private sampleNotebooks: IGalleryItem[]; + private publicNotebooks: IGalleryItem[]; + private favoriteNotebooks: IGalleryItem[]; + private publishedNotebooks: IGalleryItem[]; + private columnCount: number; + private rowCount: number; + + constructor(props: GalleryViewerComponentProps) { + super(props); + + this.state = { + sampleNotebooks: undefined, + publicNotebooks: undefined, + favoriteNotebooks: undefined, + publishedNotebooks: undefined, + selectedTab: props.selectedTab, + sortBy: props.sortBy, + searchText: props.searchText, + dialogProps: undefined + }; + + this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, false); + if (this.props.container) { + this.loadFavoriteNotebooks(this.state.searchText, this.state.sortBy, false); // Need this to show correct favorite button state + } + } + + setDialogProps = (dialogProps: DialogProps): void => { + this.setState({ dialogProps }); + }; public render(): JSX.Element { - return ( - - {this.props.data.map((githubInfo: DataModels.GitHubInfoJunoResponse) => { - const name = githubInfo.name; - const url = githubInfo.downloadUrl; - const notebookMetadata = githubInfo.metadata || { - date: "2008-12-01", - description: "Great notebook", - tags: ["favorite", "sample"], - author: "Laurent Nguyen", - views: 432, - likes: 123, - downloads: 56, - imageUrl: - "https://media.magazine.ferrari.com/images/2019/02/27/170304506-c1bcf028-b513-45f6-9f27-0cadac619c3d.jpg" - }; - const officialSamplesIndex = githubInfo.officialSamplesIndex; - const isLikedNotebook = githubInfo.isLikedNotebook; - const updateTabsStatePerNotebook = this.props.onNotebookMetadataChange - ? (notebookMetadata: DataModels.NotebookMetadata): Promise => - this.props.onNotebookMetadataChange(officialSamplesIndex, notebookMetadata) - : undefined; + const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)]; - return ( - name !== ".gitignore" && - url && ( - => - this.props.onClick(url, notebookMetadata, updateTabsStatePerNotebook, isLikedNotebook) - } - /> - ) - ); - })} + if (this.props.container) { + if (this.props.container.isGalleryPublishEnabled()) { + tabs.push(this.createTab(GalleryTab.PublicGallery, this.state.publicNotebooks)); + } + + tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks)); + + if (this.props.container.isGalleryPublishEnabled()) { + tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks)); + } + } + + const pivotProps: IPivotProps = { + onLinkClick: this.onPivotChange, + selectedKey: GalleryTab[this.state.selectedTab] + }; + + const pivotItems = tabs.map(tab => { + const pivotItemProps: IPivotItemProps = { + itemKey: GalleryTab[tab.tab], + style: { marginTop: 20 }, + headerText: GalleryUtils.getTabTitle(tab.tab) + }; + + return ( + + {tab.content} + + ); + }); + + return ( +
+ {pivotItems} + + {this.state.dialogProps && } +
+ ); + } + + private createTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo { + return { + tab, + content: this.createTabContent(data) + }; + } + + private createTabContent(data: IGalleryItem[]): JSX.Element { + return ( + + + + + + + + + + + + + + {data && this.createCardsTabContent(data)} ); } -} -export interface FullWidthTabsProps { - officialSamplesContent: DataModels.GitHubInfoJunoResponse[]; - likedNotebooksContent: DataModels.GitHubInfoJunoResponse[]; - userMetadata: DataModels.UserMetadata; - onClick: ( - url: string, - notebookMetadata: DataModels.NotebookMetadata, - onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise, - isLikedNotebook: boolean - ) => Promise; -} - -interface FullWidthTabsState { - activeTabIndex: number; - officialSamplesContent: DataModels.GitHubInfoJunoResponse[]; - likedNotebooksContent: DataModels.GitHubInfoJunoResponse[]; - userMetadata: DataModels.UserMetadata; -} - -export class FullWidthTabs extends React.Component { - private authorizationToken = CosmosClient.authorizationToken(); - private appTabs: TabComponent.Tab[]; - - constructor(props: FullWidthTabsProps) { - super(props); - this.state = { - activeTabIndex: 0, - officialSamplesContent: this.props.officialSamplesContent, - likedNotebooksContent: this.props.likedNotebooksContent, - userMetadata: this.props.userMetadata - }; - - this.appTabs = [ - { - title: "Official Samples", - content: { - className: "", - render: (): JSX.Element => ( - - ) - }, - isVisible: (): boolean => true - }, - { - title: "Liked Notebooks", - content: { - className: "", - render: (): JSX.Element => ( - - ) - }, - isVisible: (): boolean => true - } - ]; + private createCardsTabContent(data: IGalleryItem[]): JSX.Element { + return ( + + + + ); } - public updateTabsState = async ( - officialSamplesIndex: number, - notebookMetadata: DataModels.NotebookMetadata - ): Promise => { - let currentLikedNotebooksContent = [...this.state.likedNotebooksContent]; - let currentUserMetadata = { ...this.state.userMetadata }; - let currentLikedNotebooks = [...currentUserMetadata.likedNotebooks]; + private loadTabContent(tab: GalleryTab, searchText: string, sortBy: SortBy, offline: boolean): void { + switch (tab) { + case GalleryTab.OfficialSamples: + this.loadSampleNotebooks(searchText, sortBy, offline); + break; - const currentOfficialSamplesContent = [...this.state.officialSamplesContent]; - const currentOfficialSamplesObject = { ...currentOfficialSamplesContent[officialSamplesIndex] }; - const metadata = { ...currentOfficialSamplesObject.metadata }; - const metadataLikesUpdates = metadata.likes - notebookMetadata.likes; + case GalleryTab.PublicGallery: + this.loadPublicNotebooks(searchText, sortBy, offline); + break; - metadata.views = notebookMetadata.views; - metadata.downloads = notebookMetadata.downloads; - metadata.likes = notebookMetadata.likes; - currentOfficialSamplesObject.metadata = metadata; + case GalleryTab.Favorites: + this.loadFavoriteNotebooks(searchText, sortBy, offline); + break; - // Notebook has been liked. Add To likedNotebooksContent, update isLikedNotebook flag - if (metadataLikesUpdates < 0) { - currentOfficialSamplesObject.isLikedNotebook = true; - currentLikedNotebooksContent = currentLikedNotebooksContent.concat(currentOfficialSamplesObject); - currentLikedNotebooks = currentLikedNotebooks.concat(currentOfficialSamplesObject.path); - currentUserMetadata = { likedNotebooks: currentLikedNotebooks }; - } else if (metadataLikesUpdates > 0) { - // Notebook has been unliked. Remove from likedNotebooksContent after matching the path, update isLikedNotebook flag + case GalleryTab.Published: + this.loadPublishedNotebooks(searchText, sortBy, offline); + break; - currentOfficialSamplesObject.isLikedNotebook = false; - const likedNotebookIndex = currentLikedNotebooks.findIndex((path: string) => { - return path === currentOfficialSamplesObject.path; - }); - currentLikedNotebooksContent.splice(likedNotebookIndex, 1); - currentLikedNotebooks.splice(likedNotebookIndex, 1); - currentUserMetadata = { likedNotebooks: currentLikedNotebooks }; + default: + throw new Error(`Unknown tab ${tab}`); } + } - currentOfficialSamplesContent[officialSamplesIndex] = currentOfficialSamplesObject; + private async loadSampleNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise { + if (!offline) { + try { + const response = await this.props.junoClient.getSampleNotebooks(); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { + throw new Error(`Received HTTP ${response.status} when loading sample notebooks`); + } + + this.sampleNotebooks = response.data; + } catch (error) { + const message = `Failed to load sample notebooks: ${error}`; + Logger.logError(message, "GalleryViewerComponent/loadSampleNotebooks"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + } + } this.setState({ - activeTabIndex: 0, - userMetadata: currentUserMetadata, - likedNotebooksContent: currentLikedNotebooksContent, - officialSamplesContent: currentOfficialSamplesContent + sampleNotebooks: this.sampleNotebooks && [...this.sort(sortBy, this.search(searchText, this.sampleNotebooks))] + }); + } + + private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise { + if (!offline) { + try { + const response = await this.props.junoClient.getPublicNotebooks(); + 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"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + } + } + + this.setState({ + publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))] + }); + } + + private async loadFavoriteNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise { + if (!offline) { + try { + const response = await this.props.junoClient.getFavoriteNotebooks(); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { + throw new Error(`Received HTTP ${response.status} when loading favorite notebooks`); + } + + this.favoriteNotebooks = response.data; + } catch (error) { + const message = `Failed to load favorite notebooks: ${error}`; + Logger.logError(message, "GalleryViewerComponent/loadFavoriteNotebooks"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + } + } + + this.setState({ + favoriteNotebooks: this.favoriteNotebooks && [ + ...this.sort(sortBy, this.search(searchText, this.favoriteNotebooks)) + ] }); - JunoUtils.updateNotebookMetadata(this.authorizationToken, notebookMetadata).then( - async () => { - if (metadataLikesUpdates !== 0) { - JunoUtils.updateUserMetadata(this.authorizationToken, currentUserMetadata); - // TODO: update state here? + // Refresh favorite button state + if (this.state.selectedTab !== GalleryTab.Favorites) { + this.refreshSelectedTab(); + } + } + + private async loadPublishedNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise { + if (!offline) { + try { + const response = await this.props.junoClient.getPublishedNotebooks(); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { + throw new Error(`Received HTTP ${response.status} when loading published notebooks`); } - }, - error => { - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Error updating notebook metadata: ${JSON.stringify(error)}` - ); - // TODO add telemetry + + this.publishedNotebooks = response.data; + } catch (error) { + const message = `Failed to load published notebooks: ${error}`; + Logger.logError(message, "GalleryViewerComponent/loadPublishedNotebooks"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); } + } + + this.setState({ + publishedNotebooks: this.publishedNotebooks && [ + ...this.sort(sortBy, this.search(searchText, this.publishedNotebooks)) + ] + }); + } + + private search(searchText: string, data: IGalleryItem[]): IGalleryItem[] { + if (searchText) { + return data?.filter(item => this.isGalleryItemPresent(searchText, item)); + } + + return data; + } + + private isGalleryItemPresent(searchText: string, item: IGalleryItem): boolean { + const toSearch = searchText.trim().toUpperCase(); + const searchData: string[] = [ + item.author.toUpperCase(), + item.description.toUpperCase(), + item.name.toUpperCase(), + ...item.tags?.map(tag => tag.toUpperCase()) + ]; + + for (const data of searchData) { + if (data?.indexOf(toSearch) !== -1) { + return true; + } + } + return false; + } + + private sort(sortBy: SortBy, data: IGalleryItem[]): IGalleryItem[] { + return data?.sort((a, b) => { + switch (sortBy) { + case SortBy.MostViewed: + return b.views - a.views; + case SortBy.MostDownloaded: + return b.downloads - a.downloads; + case SortBy.MostFavorited: + return b.favorites - a.favorites; + case SortBy.MostRecent: + return Date.parse(b.created) - Date.parse(a.created); + default: + throw new Error(`Unknown sorting condition ${sortBy}`); + } + }); + } + + private refreshSelectedTab(item?: IGalleryItem): void { + if (item) { + this.updateGalleryItem(item); + } + this.loadTabContent(this.state.selectedTab, this.state.searchText, this.state.sortBy, true); + } + + private updateGalleryItem(updatedItem: IGalleryItem): void { + this.replaceGalleryItem(updatedItem, this.sampleNotebooks); + this.replaceGalleryItem(updatedItem, this.publicNotebooks); + this.replaceGalleryItem(updatedItem, this.favoriteNotebooks); + this.replaceGalleryItem(updatedItem, this.publishedNotebooks); + } + + private replaceGalleryItem(item: IGalleryItem, items?: IGalleryItem[]): void { + const index = items?.findIndex(value => value.id === item.id); + if (index !== -1) { + items?.splice(index, 1, item); + } + } + + private getPageSpecification = (itemIndex?: number, visibleRect?: IRectangle): IPageSpecification => { + this.columnCount = Math.floor(visibleRect.width / GalleryCardComponent.CARD_WIDTH); + this.rowCount = Math.floor(visibleRect.height / GalleryCardComponent.CARD_HEIGHT); + + return { + height: visibleRect.height, + itemCount: this.columnCount * this.rowCount + }; + }; + + private onRenderCell = (data?: IGalleryItem): JSX.Element => { + const isFavorite = this.favoriteNotebooks?.find(item => item.id === data.id) !== undefined; + const props: GalleryCardComponentProps = { + data, + isFavorite, + showDelete: this.state.selectedTab === GalleryTab.Published, + onClick: () => this.openNotebook(data, isFavorite), + onTagClick: this.loadTaggedItems, + onFavoriteClick: () => this.favoriteItem(data), + onUnfavoriteClick: () => this.unfavoriteItem(data), + onDownloadClick: () => this.downloadItem(data), + onDeleteClick: () => this.deleteItem(data) + }; + + return ( +
+ +
); }; - private onTabIndexChange = (activeTabIndex: number): void => this.setState({ activeTabIndex }); - - public render(): JSX.Element { - return ( - - ); - } -} - -export interface GalleryViewerContainerComponentProps { - container: ViewModels.Explorer; -} - -interface GalleryViewerContainerComponentState { - officialSamplesData: DataModels.GitHubInfoJunoResponse[]; - likedNotebooksData: DataModels.LikedNotebooksJunoResponse; -} - -export class GalleryViewerContainerComponent extends React.Component< - GalleryViewerContainerComponentProps, - GalleryViewerContainerComponentState -> { - constructor(props: GalleryViewerContainerComponentProps) { - super(props); - this.state = { - officialSamplesData: undefined, - likedNotebooksData: undefined - }; - } - - componentDidMount(): void { - const authToken = CosmosClient.authorizationToken(); - JunoUtils.getOfficialSampleNotebooks(authToken).then( - (data1: DataModels.GitHubInfoJunoResponse[]) => { - const officialSamplesData = data1; - - JunoUtils.getLikedNotebooks(authToken).then( - (data2: DataModels.LikedNotebooksJunoResponse) => { - const likedNotebooksData = data2; - - officialSamplesData.map((value: DataModels.GitHubInfoJunoResponse, index: number) => { - value.officialSamplesIndex = index; - value.isLikedNotebook = likedNotebooksData.userMetadata.likedNotebooks.includes(value.path); - }); - - likedNotebooksData.likedNotebooksContent.map((value: DataModels.GitHubInfoJunoResponse) => { - value.isLikedNotebook = true; - value.officialSamplesIndex = officialSamplesData.findIndex( - (officialSample: DataModels.GitHubInfoJunoResponse) => { - return officialSample.path === value.path; - } - ); - }); - - this.setState({ - officialSamplesData: officialSamplesData, - likedNotebooksData: likedNotebooksData - }); - }, - error => { - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Error fetching liked notebooks: ${JSON.stringify(error)}` - ); - // TODO Add telemetry - } - ); - }, - error => { - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Error fetching sample notebooks: ${JSON.stringify(error)}` - ); - // TODO Add telemetry - } - ); - } - - public render(): JSX.Element { - return this.state.officialSamplesData && this.state.likedNotebooksData ? ( - - ) : ( - <> - ); - } -} - -export interface GalleryViewerComponentProps { - container: ViewModels.Explorer; - officialSamplesData: DataModels.GitHubInfoJunoResponse[]; - likedNotebookData: DataModels.LikedNotebooksJunoResponse; -} - -export class GalleryViewerComponent extends React.Component { - public render(): JSX.Element { - return this.props.container ? ( -
- -
- ) : ( -
- -
- ); - } - - public getOfficialSamplesData(): DataModels.GitHubInfoJunoResponse[] { - return this.props.officialSamplesData; - } - - public getLikedNotebookData(): DataModels.LikedNotebooksJunoResponse { - return this.props.likedNotebookData; - } - - public openNotebookViewer = async ( - url: string, - notebookMetadata: DataModels.NotebookMetadata, - onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise, - isLikedNotebook: boolean - ): Promise => { - if (!this.props.container) { - SessionStorageUtility.setEntryString( - StorageKey.NotebookMetadata, - notebookMetadata ? JSON.stringify(notebookMetadata) : undefined - ); - SessionStorageUtility.setEntryString(StorageKey.NotebookName, path.basename(url)); - window.open(`${config.hostedExplorerURL}notebookViewer.html?notebookurl=${url}`, "_blank"); + private openNotebook = (data: IGalleryItem, isFavorite: boolean): void => { + if (this.props.container && this.props.junoClient) { + this.props.container.openGallery(this.props.junoClient.getNotebookContentUrl(data.id), data, isFavorite); } else { - this.props.container.openNotebookViewer(url, notebookMetadata, onNotebookMetadataChange, isLikedNotebook); + const params = new URLSearchParams({ + [GalleryUtils.NotebookViewerParams.NotebookUrl]: this.props.junoClient.getNotebookContentUrl(data.id), + [GalleryUtils.NotebookViewerParams.GalleryItemId]: data.id + }); + + window.open(`/notebookViewer.html?${params.toString()}`); } }; + + private loadTaggedItems = (tag: string): void => { + const searchText = tag; + this.setState({ + searchText + }); + + this.loadTabContent(this.state.selectedTab, searchText, this.state.sortBy, true); + this.props.onSearchTextChange && this.props.onSearchTextChange(searchText); + }; + + private favoriteItem = async (data: IGalleryItem): Promise => { + GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, data, (item: IGalleryItem) => { + if (this.favoriteNotebooks) { + this.favoriteNotebooks.push(item); + } else { + this.favoriteNotebooks = [item]; + } + this.refreshSelectedTab(item); + }); + }; + + private unfavoriteItem = async (data: IGalleryItem): Promise => { + GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, data, (item: IGalleryItem) => { + this.favoriteNotebooks = this.favoriteNotebooks?.filter(value => value.id !== item.id); + this.refreshSelectedTab(item); + }); + }; + + private downloadItem = async (data: IGalleryItem): Promise => { + GalleryUtils.downloadItem(this, this.props.container, this.props.junoClient, data, item => + this.refreshSelectedTab(item) + ); + }; + + private deleteItem = async (data: IGalleryItem): Promise => { + GalleryUtils.deleteItem(this.props.container, this.props.junoClient, data, item => { + this.publishedNotebooks = this.publishedNotebooks.filter(notebook => item.id !== notebook.id); + this.refreshSelectedTab(item); + }); + }; + + private onPivotChange = (item: PivotItem): void => { + const selectedTab = GalleryTab[item.props.itemKey as keyof typeof GalleryTab]; + const searchText: string = undefined; + this.setState({ + selectedTab, + searchText + }); + + this.loadTabContent(selectedTab, searchText, this.state.sortBy, false); + this.props.onSelectedTabChange && this.props.onSelectedTabChange(selectedTab); + }; + + private onSearchBoxChange = (event?: React.ChangeEvent, newValue?: string): void => { + const searchText = newValue; + this.setState({ + searchText + }); + + this.loadTabContent(this.state.selectedTab, searchText, this.state.sortBy, true); + this.props.onSearchTextChange && this.props.onSearchTextChange(searchText); + }; + + private onDropdownChange = (event: React.FormEvent, option?: IDropdownOption): void => { + const sortBy = option.key as SortBy; + this.setState({ + sortBy + }); + + this.loadTabContent(this.state.selectedTab, this.state.searchText, sortBy, true); + this.props.onSortByChange && this.props.onSortByChange(sortBy); + }; } diff --git a/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap b/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap index d5e9a8123..e5585d84c 100644 --- a/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookGallery/__snapshots__/GalleryViewerComponent.test.tsx.snap @@ -1,54 +1,88 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FullWidthTabs renders 1`] = ` - -`; - -exports[`GalleryCardComponent renders 1`] = ` +exports[`GalleryViewerComponent renders 1`] = `
- + + + + + + + + + + Sort by + + + + + + + + +
`; - -exports[`GalleryCardsComponent renders 1`] = ` - -`; - -exports[`GalleryViewerContainerComponent renders 1`] = ``; diff --git a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx index a79fe2aac..1fb97e48e 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.test.tsx @@ -1,16 +1,30 @@ -import React from "react"; import { shallow } from "enzyme"; -import { NotebookMetadataComponentProps, NotebookMetadataComponent } from "./NotebookMetadataComponent"; +import React from "react"; +import { NotebookMetadataComponent, NotebookMetadataComponentProps } from "./NotebookMetadataComponent"; describe("NotebookMetadataComponent", () => { it("renders un-liked notebook", () => { const props: NotebookMetadataComponentProps = { - notebookName: "My notebook", - container: undefined, - notebookMetadata: undefined, - notebookContent: {}, - onNotebookMetadataChange: () => Promise.resolve(), - isLikedNotebook: false + data: { + id: "id", + name: "name", + description: "description", + author: "author", + thumbnailUrl: "thumbnailUrl", + created: "created", + gitSha: "gitSha", + tags: ["tag"], + isSample: false, + downloads: 0, + favorites: 0, + views: 0 + }, + isFavorite: false, + downloadButtonText: "Download", + onTagClick: undefined, + onDownloadClick: undefined, + onFavoriteClick: undefined, + onUnfavoriteClick: undefined }; const wrapper = shallow(); @@ -19,12 +33,26 @@ describe("NotebookMetadataComponent", () => { it("renders liked notebook", () => { const props: NotebookMetadataComponentProps = { - notebookName: "My notebook", - container: undefined, - notebookMetadata: undefined, - notebookContent: {}, - onNotebookMetadataChange: () => Promise.resolve(), - isLikedNotebook: true + data: { + id: "id", + name: "name", + description: "description", + author: "author", + thumbnailUrl: "thumbnailUrl", + created: "created", + gitSha: "gitSha", + tags: ["tag"], + isSample: false, + downloads: 0, + favorites: 0, + views: 0 + }, + isFavorite: true, + downloadButtonText: "Download", + onTagClick: undefined, + onDownloadClick: undefined, + onFavoriteClick: undefined, + onUnfavoriteClick: undefined }; const wrapper = shallow(); diff --git a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx index 04381750d..954824d2a 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookMetadataComponent.tsx @@ -1,189 +1,85 @@ /** * Wrapper around Notebook metadata */ - -import * as React from "react"; -import * as ViewModels from "../../../Contracts/ViewModels"; -import { NotebookMetadata } from "../../../Contracts/DataModels"; -import { initializeIcons } from "office-ui-fabric-react/lib/Icons"; -import { Icon, Persona, Text, IconButton } from "office-ui-fabric-react"; import { - siteTextStyles, - subtleIconStyles, - iconStyles, - iconButtonStyles, - mainHelpfulTextStyles, - subtleHelpfulTextStyles, - helpfulTextStyles -} from "../NotebookGallery/Cards/CardStyleConstants"; - + FontWeights, + Icon, + IconButton, + Link, + Persona, + PersonaSize, + PrimaryButton, + Stack, + Text +} from "office-ui-fabric-react"; +import * as React from "react"; +import { IGalleryItem } from "../../../Juno/JunoClient"; +import { FileSystemUtil } from "../../Notebook/FileSystemUtil"; import "./NotebookViewerComponent.less"; -initializeIcons(); - export interface NotebookMetadataComponentProps { - notebookName: string; - container: ViewModels.Explorer; - notebookMetadata: NotebookMetadata; - notebookContent: any; - onNotebookMetadataChange: (newNotebookMetadata: NotebookMetadata) => Promise; - isLikedNotebook: boolean; + data: IGalleryItem; + isFavorite: boolean; + downloadButtonText: string; + onTagClick: (tag: string) => void; + onFavoriteClick: () => void; + onUnfavoriteClick: () => void; + onDownloadClick: () => void; } -interface NotebookMetadatComponentState { - liked: boolean; - notebookMetadata: NotebookMetadata; -} - -export class NotebookMetadataComponent extends React.Component< - NotebookMetadataComponentProps, - NotebookMetadatComponentState -> { - constructor(props: NotebookMetadataComponentProps) { - super(props); - this.state = { - liked: this.props.isLikedNotebook, - notebookMetadata: this.props.notebookMetadata - }; - } - - private onDownloadClick = (newNotebookName: string) => { - this.props.container - .importAndOpenFromGallery(this.props.notebookName, newNotebookName, JSON.stringify(this.props.notebookContent)) - .then(() => { - if (this.props.notebookMetadata) { - if (this.props.onNotebookMetadataChange) { - const notebookMetadata = { ...this.state.notebookMetadata }; - notebookMetadata.downloads += 1; - this.props.onNotebookMetadataChange(notebookMetadata).then(() => { - this.setState({ notebookMetadata: notebookMetadata }); - }); - } - } - }); - }; - - componentDidMount() { - if (this.props.onNotebookMetadataChange) { - const notebookMetadata = { ...this.state.notebookMetadata }; - if (this.props.notebookMetadata) { - notebookMetadata.views += 1; - this.props.onNotebookMetadataChange(notebookMetadata).then(() => { - this.setState({ notebookMetadata: notebookMetadata }); - }); - } - } - } - - private onLike = (): void => { - if (this.props.onNotebookMetadataChange) { - const notebookMetadata = { ...this.state.notebookMetadata }; - let liked: boolean; - if (this.state.liked) { - liked = false; - notebookMetadata.likes -= 1; - } else { - liked = true; - notebookMetadata.likes += 1; - } - - this.props.onNotebookMetadataChange(notebookMetadata).then(() => { - this.setState({ liked: liked, notebookMetadata: notebookMetadata }); - }); - } - }; - - private onDownload = (): void => { - const promptForNotebookName = () => { - return new Promise((resolve, reject) => { - let newNotebookName = this.props.notebookName; - this.props.container.showOkCancelTextFieldModalDialog( - "Save notebook as", - undefined, - "Ok", - () => resolve(newNotebookName), - "Cancel", - () => reject(new Error("New notebook name dialog canceled")), - { - label: "New notebook name:", - autoAdjustHeight: true, - multiline: true, - rows: 3, - defaultValue: this.props.notebookName, - onChange: (_, newValue: string) => { - newNotebookName = newValue; - } - } - ); - }); - }; - - promptForNotebookName().then((newNotebookName: string) => { - this.onDownloadClick(newNotebookName); - }); - }; - +export class NotebookMetadataComponent extends React.Component { public render(): JSX.Element { + const options: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric" + }; + + const dateString = new Date(this.props.data.created).toLocaleString("default", options); + return ( -
-

{this.props.notebookName}

+ + + + {FileSystemUtil.stripExtension(this.props.data.name, "ipynb")} + + + + {this.props.data.favorites} likes + + + - {this.props.notebookMetadata && ( -
- {this.props.container ? ( - - ) : ( - - )} - - {this.state.notebookMetadata.likes} likes - -
- )} + + + {dateString} + + {this.props.data.views} + + + + {this.props.data.downloads} + + - {this.props.container && ( - - )} + + {this.props.data.tags?.map((tag, index, array) => ( + + this.props.onTagClick(tag)}>{tag} + {index === array.length - 1 ? <> : ", "} + + ))} + - {this.props.notebookMetadata && ( - <> -
- -
-
-
- - - {this.state.notebookMetadata.views} - - - - {this.state.notebookMetadata.downloads} - -
- - {this.props.notebookMetadata.tags.join(", ")} - -
-
- - Description: -

{this.props.notebookMetadata.description}

-
-
- - )} -
+ + Description + + + {this.props.data.description} +
); } } diff --git a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.less b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.less index a99718f94..b5c710bb3 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.less +++ b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.less @@ -1,7 +1,7 @@ @import "../../../../less/Common/Constants"; .notebookViewerContainer { - padding: @DefaultSpace; + padding: 30px; height: 100%; width: 100%; overflow-y: auto; diff --git a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx index 7dba59508..0e11ad122 100644 --- a/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx @@ -1,36 +1,44 @@ /** * Wrapper around Notebook Viewer Read only content */ - +import { Notebook } from "@nteract/commutable"; +import { createContentRef } from "@nteract/core"; +import { Icon, Link } from "office-ui-fabric-react"; import * as React from "react"; +import { contents } from "rx-jupyter"; +import * as Logger from "../../../Common/Logger"; import * as ViewModels from "../../../Contracts/ViewModels"; +import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; +import * as GalleryUtils from "../../../Utils/GalleryUtils"; +import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils"; +import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; import { NotebookClientV2 } from "../../Notebook/NotebookClientV2"; import { NotebookComponentBootstrapper } from "../../Notebook/NotebookComponent/NotebookComponentBootstrapper"; -import { createContentRef } from "@nteract/core"; import NotebookReadOnlyRenderer from "../../Notebook/NotebookRenderer/NotebookReadOnlyRenderer"; -import { contents } from "rx-jupyter"; -import { NotebookMetadata } from "../../../Contracts/DataModels"; +import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComponent"; import { NotebookMetadataComponent } from "./NotebookMetadataComponent"; import "./NotebookViewerComponent.less"; export interface NotebookViewerComponentProps { - notebookName: string; - notebookUrl: string; container?: ViewModels.Explorer; - notebookMetadata: NotebookMetadata; - onNotebookMetadataChange?: (newNotebookMetadata: NotebookMetadata) => Promise; - isLikedNotebook?: boolean; - hideInputs?: boolean; + junoClient?: JunoClient; + notebookUrl: string; + galleryItem?: IGalleryItem; + isFavorite?: boolean; + backNavigationText: string; + onBackClick: () => void; + onTagClick: (tag: string) => void; } interface NotebookViewerComponentState { - content: any; + content: Notebook; + galleryItem?: IGalleryItem; + isFavorite?: boolean; + dialogProps: DialogProps; } -export class NotebookViewerComponent extends React.Component< - NotebookViewerComponentProps, - NotebookViewerComponentState -> { +export class NotebookViewerComponent extends React.Component + implements GalleryUtils.DialogEnabledComponent { private clientManager: NotebookClientV2; private notebookComponentBootstrapper: NotebookComponentBootstrapper; @@ -52,40 +60,118 @@ export class NotebookViewerComponent extends React.Component< contentRef: createContentRef() }); - this.state = { content: undefined }; + this.state = { + content: undefined, + galleryItem: props.galleryItem, + isFavorite: props.isFavorite, + dialogProps: undefined + }; + + this.loadNotebookContent(); } - private async getJsonNotebookContent(): Promise { - const response: Response = await fetch(this.props.notebookUrl); - if (response.ok) { - return await response.json(); - } else { - return undefined; + setDialogProps = (dialogProps: DialogProps): void => { + this.setState({ dialogProps }); + }; + + private async loadNotebookContent(): Promise { + try { + const response = await fetch(this.props.notebookUrl); + if (!response.ok) { + throw new Error(`Received HTTP ${response.status} while fetching ${this.props.notebookUrl}`); + } + + const notebook: Notebook = await response.json(); + this.notebookComponentBootstrapper.setContent("json", notebook); + this.setState({ content: notebook }); + + if (this.props.galleryItem) { + const response = await this.props.junoClient.increaseNotebookViews(this.props.galleryItem.id); + if (!response.data) { + throw new Error(`Received HTTP ${response.status} while increasing notebook views`); + } + + this.setState({ galleryItem: response.data }); + } + } catch (error) { + const message = `Failed to load notebook content: ${error}`; + Logger.logError(message, "NotebookViewerComponent/loadNotebookContent"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); } } - componentDidMount() { - this.getJsonNotebookContent().then((jsonContent: any) => { - this.notebookComponentBootstrapper.setContent("json", jsonContent); - this.setState({ content: jsonContent }); - }); - } - public render(): JSX.Element { return (
- - {this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, { - hideInputs: this.props.hideInputs - })} + {this.props.backNavigationText ? ( + + {this.props.backNavigationText} + + ) : ( + <> + )} + + {this.state.galleryItem ? ( +
+ +
+ ) : ( + <> + )} + + {this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, { hideInputs: true })} + + {this.state.dialogProps && }
); } + + public static getDerivedStateFromProps( + props: NotebookViewerComponentProps, + state: NotebookViewerComponentState + ): Partial { + let galleryItem = props.galleryItem; + let isFavorite = props.isFavorite; + + if (state.galleryItem !== undefined) { + galleryItem = state.galleryItem; + } + + if (state.isFavorite !== undefined) { + isFavorite = state.isFavorite; + } + + return { + galleryItem, + isFavorite + }; + } + + private favoriteItem = async (): Promise => { + GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, item => + this.setState({ galleryItem: item, isFavorite: true }) + ); + }; + + private unfavoriteItem = async (): Promise => { + GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, item => + this.setState({ galleryItem: item, isFavorite: false }) + ); + }; + + private downloadItem = async (): Promise => { + GalleryUtils.downloadItem(this, this.props.container, this.props.junoClient, this.state.galleryItem, item => + this.setState({ galleryItem: item }) + ); + }; } diff --git a/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap b/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap index b0b6c06f9..d34c66992 100644 --- a/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap +++ b/src/Explorer/Controls/NotebookViewer/__snapshots__/NotebookMetadataComponent.test.tsx.snap @@ -1,25 +1,199 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`NotebookMetadataComponent renders liked notebook 1`] = ` -
-

- My notebook -

-
+ + name + + + + 0 + likes + + +
+ + + + Invalid Date + + + + + 0 + + + + 0 + + + + + + tag + + + + + Description + + + description + + `; exports[`NotebookMetadataComponent renders un-liked notebook 1`] = ` -
-

- My notebook -

-
+ + name + + + + 0 + likes + + + + + + + Invalid Date + + + + + 0 + + + + 0 + + + + + + tag + + + + + Description + + + description + + `; diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 0dd7a41fa..dce15417a 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -49,19 +49,15 @@ import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane"; import { ExplorerMetrics } from "../Common/Constants"; import { ExplorerSettings } from "../Shared/ExplorerSettings"; import { FileSystemUtil } from "./Notebook/FileSystemUtil"; -import { GitHubOAuthService } from "../GitHub/GitHubOAuthService"; -import { GitHubReposPane } from "./Panes/GitHubReposPane"; import { handleOpenAction } from "./OpenActions"; -import { IContentProvider } from "@nteract/core"; import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation"; -import { JunoClient } from "../Juno/JunoClient"; +import { IGalleryItem } from "../Juno/JunoClient"; import { LibraryManagePane } from "./Panes/LibraryManagePane"; import { LoadQueryPane } from "./Panes/LoadQueryPane"; import * as Logger from "../Common/Logger"; import { ManageSparkClusterPane } from "./Panes/ManageSparkClusterPane"; import { MessageHandler } from "../Common/MessageHandler"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; -import { NotebookContentProvider } from "./Notebook/NotebookComponent/NotebookContentProvider"; import { NotebookUtil } from "./Notebook/NotebookUtil"; import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager"; import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter"; @@ -86,6 +82,7 @@ import { TableColumnOptionsPane } from "./Panes/Tables/TableColumnOptionsPane"; import { UploadFilePane } from "./Panes/UploadFilePane"; import { UploadItemsPane } from "./Panes/UploadItemsPane"; import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter"; +import { ReactAdapter } from "../Bindings/ReactBindingHandler"; BindingHandlersRegisterer.registerBindingHandlers(); // Hold a reference to ComponentRegisterer to prevent transpiler to ignore import @@ -199,10 +196,13 @@ export default class Explorer implements ViewModels.Explorer { public libraryManagePane: ViewModels.ContextualPane; public clusterLibraryPane: ViewModels.ContextualPane; public gitHubReposPane: ViewModels.ContextualPane; + public publishNotebookPaneAdapter: ReactAdapter; // features public isGalleryEnabled: ko.Computed; + public isGalleryPublishEnabled: ko.Computed; public isGitHubPaneEnabled: ko.Observable; + public isPublishNotebookPaneEnabled: ko.Observable; public isHostedDataExplorerEnabled: ko.Computed; public isRightPanelV2Enabled: ko.Computed; public canExceedMaximumValue: ko.Computed; @@ -223,11 +223,7 @@ export default class Explorer implements ViewModels.Explorer { // Notebooks public isNotebookEnabled: ko.Observable; public isNotebooksEnabledForAccount: ko.Observable; - private notebookClient: ViewModels.INotebookContainerClient; - private notebookContentClient: ViewModels.INotebookContentClient; public notebookServerInfo: ko.Observable; - public notebookContentProvider: IContentProvider; - public gitHubOAuthService: GitHubOAuthService; public notebookWorkspaceManager: ViewModels.NotebookWorkspaceManager; public sparkClusterManager: ViewModels.SparkClusterManager; public sparkClusterConnectionInfo: ko.Observable; @@ -239,6 +235,7 @@ export default class Explorer implements ViewModels.Explorer { public isSynapseLinkUpdating: ko.Observable; public isNotebookTabActive: ko.Computed; public memoryUsageInfo: ko.Observable; + public notebookManager?: any; // This is dynamically loaded private _panes: ViewModels.ContextualPane[] = []; private _importExplorerConfigComplete: boolean = false; @@ -409,7 +406,11 @@ export default class Explorer implements ViewModels.Explorer { this.shouldShowDataAccessExpiryDialog = ko.observable(false); this.shouldShowContextSwitchPrompt = ko.observable(false); this.isGalleryEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableGallery)); + this.isGalleryPublishEnabled = ko.computed(() => + this.isFeatureEnabled(Constants.Features.enableGalleryPublish) + ); this.isGitHubPaneEnabled = ko.observable(false); + this.isPublishNotebookPaneEnabled = ko.observable(false); this.canExceedMaximumValue = ko.computed(() => this.isFeatureEnabled(Constants.Features.canExceedMaximumValue) @@ -937,127 +938,33 @@ export default class Explorer implements ViewModels.Explorer { startKey ); - const junoClient = new JunoClient(this.databaseAccount); - this.isNotebookEnabled = ko.observable(false); - this.isNotebookEnabled.subscribe(async (isEnabled: boolean) => { - this.refreshCommandBarButtons(); + this.isNotebookEnabled.subscribe(async () => { + if (!this.notebookManager) { + const notebookManagerModule = await import( + /* webpackChunkName: "NotebookManager" */ "./Notebook/NotebookManager" + ); + this.notebookManager = new notebookManagerModule.default(); + this.notebookManager.initialize({ + container: this, + dialogProps: this._dialogProps, + notebookBasePath: this.notebookBasePath, + resourceTree: this.resourceTree, + refreshCommandBarButtons: () => this.refreshCommandBarButtons(), + refreshNotebookList: () => this.refreshNotebookList() + }); - this.gitHubOAuthService = new GitHubOAuthService(junoClient); - - const GitHubClientModule = await import(/* webpackChunkName: "GitHubClient" */ "../GitHub/GitHubClient"); - const gitHubClient = new GitHubClientModule.GitHubClient(config.AZURESAMPLESCOSMOSDBPAT, error => { - Logger.logError(error, "Explorer/GitHubClient errorCallback"); - - if (error.status === Constants.HttpStatusCodes.Unauthorized) { - this.gitHubOAuthService?.resetToken(); - - this.showOkCancelModalDialog( - undefined, - "Cosmos DB cannot access your Github account anymore. Please connect to GitHub again.", - "Connect to GitHub", - () => this.gitHubReposPane?.open(), - "Cancel", - undefined - ); - } - }); - - this.gitHubReposPane = new GitHubReposPane({ - documentClientUtility: this.documentClientUtility, - id: "gitHubReposPane", - visible: ko.observable(false), - container: this, - junoClient, - gitHubClient - }); - - this.isGitHubPaneEnabled(true); - - this.gitHubOAuthService.getTokenObservable().subscribe(token => { - gitHubClient.setToken(token?.access_token ? token.access_token : config.AZURESAMPLESCOSMOSDBPAT); - - if (this.gitHubReposPane?.visible()) { - this.gitHubReposPane.open(); - } - - this.refreshCommandBarButtons(); - this.refreshNotebookList(); - }); - - if (this.isGalleryEnabled()) { - this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab"); - this.notebookViewerTab = await import(/* webpackChunkName: "NotebookViewerTab" */ "./Tabs/NotebookViewerTab"); + this.gitHubReposPane = this.notebookManager.gitHubReposPane; + this.isGitHubPaneEnabled(true); } - const promptForCommitMsg = (title: string, primaryButtonLabel: string) => { - return new Promise((resolve, reject) => { - let commitMsg: string = "Committed from Azure Cosmos DB Notebooks"; - this.showOkCancelTextFieldModalDialog( - title || "Commit", - undefined, - primaryButtonLabel || "Commit", - () => { - TelemetryProcessor.trace(Action.NotebooksGitHubCommit, ActionModifiers.Mark, { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.Notebook - }); - resolve(commitMsg); - }, - "Cancel", - () => reject(new Error("Commit dialog canceled")), - { - label: "Commit message", - autoAdjustHeight: true, - multiline: true, - defaultValue: commitMsg, - rows: 3, - onChange: (_, newValue: string) => { - commitMsg = newValue; - this._dialogProps().primaryButtonDisabled = !commitMsg; - this._dialogProps.valueHasMutated(); - } - }, - !commitMsg - ); - }); - }; - - const GitHubContentProviderModule = await import( - /* webpackChunkName: "rx-jupyter" */ "../GitHub/GitHubContentProvider" - ); - const RXJupyterModule = await import(/* webpackChunkName: "rx-jupyter" */ "rx-jupyter"); - this.notebookContentProvider = new NotebookContentProvider( - new GitHubContentProviderModule.GitHubContentProvider({ gitHubClient, promptForCommitMsg }), - RXJupyterModule.contents.JupyterContentProvider - ); - - const NotebookContainerClientModule = await import( - /* webpackChunkName: "NotebookContainerClient" */ "./Notebook/NotebookContainerClient" - ); - - this.notebookClient = new NotebookContainerClientModule.NotebookContainerClient( - this.notebookServerInfo, - () => this.initNotebooks(this.databaseAccount()), - (update: DataModels.MemoryUsageInfo) => this.memoryUsageInfo(update) - ); - - const NotebookContentClientModule = await import( - /* webpackChunkName: "NotebookContentClient" */ "./Notebook/NotebookContentClient" - ); - this.notebookContentClient = new NotebookContentClientModule.NotebookContentClient( - this.notebookServerInfo, - this.notebookBasePath, - this.notebookContentProvider - ); - + this.refreshCommandBarButtons(); this.refreshNotebookList(); }); this.isSparkEnabled = ko.observable(false); this.isSparkEnabled.subscribe((isEnabled: boolean) => this.refreshCommandBarButtons()); - this.resourceTree = new ResourceTreeAdapter(this, junoClient); + this.resourceTree = new ResourceTreeAdapter(this); this.resourceTreeForResourceToken = new ResourceTreeAdapterForResourceToken(this); this.notebookServerInfo = ko.observable({ notebookServerEndpoint: undefined, @@ -1887,7 +1794,7 @@ export default class Explorer implements ViewModels.Explorer { } public resetNotebookWorkspace() { - if (!this.isNotebookEnabled() || !this.notebookClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookClient) { const error = "Attempt to reset notebook workspace, but notebook is not enabled"; Logger.logError(error, "Explorer/resetNotebookWorkspace"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); @@ -1994,7 +1901,7 @@ export default class Explorer implements ViewModels.Explorer { this._closeModalDialog(); const id = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Resetting notebook workspace"); try { - await this.notebookClient.resetWorkspace(); + await this.notebookManager?.notebookClient.resetWorkspace(); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully reset notebook workspace"); TelemetryProcessor.traceSuccess(Action.ResetNotebookWorkspace); } catch (error) { @@ -2563,17 +2470,17 @@ export default class Explorer implements ViewModels.Explorer { } private uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to upload notebook, but notebook is not enabled"; Logger.logError(error, "Explorer/uploadFile"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); throw new Error(error); } - const promise = this.notebookContentClient.uploadFileAsync(name, content, parent); + const promise = this.notebookManager?.notebookContentClient.uploadFileAsync(name, content, parent); promise .then(() => this.resourceTree.triggerRender()) - .catch(reason => this.showOkModalDialog("Unable to upload file", reason)); + .catch((reason: any) => this.showOkModalDialog("Unable to upload file", reason)); return promise; } @@ -2582,7 +2489,7 @@ export default class Explorer implements ViewModels.Explorer { const item = NotebookUtil.createNotebookContentItem(name, path, "file"); const parent = this.resourceTree.myNotebooksContentRoot; - if (parent && parent.children && this.isNotebookEnabled() && this.notebookClient) { + if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) { if (this._filePathToImportAndOpen === path) { this._filePathToImportAndOpen = null; // we don't want to try opening this path again } @@ -2601,15 +2508,10 @@ export default class Explorer implements ViewModels.Explorer { return Promise.resolve(false); } - public async importAndOpenFromGallery(path: string, newName: string, content: any): Promise { - const name = newName; + public async importAndOpenFromGallery(name: string, content: string): Promise { const parent = this.resourceTree.myNotebooksContentRoot; - if (parent && this.isNotebookEnabled() && this.notebookClient) { - if (this._filePathToImportAndOpen === path) { - this._filePathToImportAndOpen = undefined; // we don't want to try opening this path again - } - + if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) { const existingItem = _.find(parent.children, node => node.name === name); if (existingItem) { this.showOkModalDialog("Download failed", "Notebook with the same name already exists."); @@ -2620,10 +2522,17 @@ export default class Explorer implements ViewModels.Explorer { return this.openNotebook(uploadedItem); } - this._filePathToImportAndOpen = path; // we'll try opening this path later on return Promise.resolve(false); } + public publishNotebook(name: string, content: string): void { + if (this.notebookManager) { + this.notebookManager.openPublishNotebookPane(name, content); + this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; + this.isPublishNotebookPaneEnabled(true); + } + } + public showOkModalDialog(title: string, msg: string): void { this._dialogProps({ isModal: true, @@ -2756,7 +2665,7 @@ export default class Explorer implements ViewModels.Explorer { } public renameNotebook(notebookFile: NotebookContentItem): Q.Promise { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to rename notebook, but notebook is not enabled"; Logger.logError(error, "Explorer/renameNotebook"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); @@ -2784,7 +2693,7 @@ export default class Explorer implements ViewModels.Explorer { paneTitle: "Rename Notebook", submitButtonLabel: "Rename", defaultInput: FileSystemUtil.stripExtension(notebookFile.name, "ipynb"), - onSubmit: (input: string) => this.notebookContentClient.renameNotebook(notebookFile, input) + onSubmit: (input: string) => this.notebookManager?.notebookContentClient.renameNotebook(notebookFile, input) }) .then(newNotebookFile => { this.openedTabs() @@ -2806,7 +2715,7 @@ export default class Explorer implements ViewModels.Explorer { } public onCreateDirectory(parent: NotebookContentItem): Q.Promise { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to create notebook directory, but notebook is not enabled"; Logger.logError(error, "Explorer/onCreateDirectory"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); @@ -2821,32 +2730,32 @@ export default class Explorer implements ViewModels.Explorer { paneTitle: "Create new directory", submitButtonLabel: "Create", defaultInput: "", - onSubmit: (input: string) => this.notebookContentClient.createDirectory(parent, input) + onSubmit: (input: string) => this.notebookManager?.notebookContentClient.createDirectory(parent, input) }); result.then(() => this.resourceTree.triggerRender()); return result; } public readFile(notebookFile: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to read file, but notebook is not enabled"; Logger.logError(error, "Explorer/downloadFile"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); throw new Error(error); } - return this.notebookContentClient.readFileContent(notebookFile.path); + return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path); } public downloadFile(notebookFile: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to download file, but notebook is not enabled"; Logger.logError(error, "Explorer/downloadFile"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); throw new Error(error); } - return this.notebookContentClient.readFileContent(notebookFile.path).then( + return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path).then( (content: string) => { const blob = new Blob([content], { type: "octet/stream" }); if (navigator.msSaveBlob) { @@ -2866,7 +2775,7 @@ export default class Explorer implements ViewModels.Explorer { downloadLink.remove(); } }, - error => { + (error: any) => { NotificationConsoleUtils.logConsoleMessage( ConsoleDataType.Error, `Could not download notebook ${JSON.stringify(error)}` @@ -3013,7 +2922,7 @@ export default class Explorer implements ViewModels.Explorer { } private refreshNotebookList = async (): Promise => { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { return; } @@ -3024,7 +2933,7 @@ export default class Explorer implements ViewModels.Explorer { }; public deleteNotebookFile(item: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to delete notebook file, but notebook is not enabled"; Logger.logError(error, "Explorer/deleteNotebookFile"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); @@ -3055,11 +2964,11 @@ export default class Explorer implements ViewModels.Explorer { return Promise.reject(); } - return this.notebookContentClient.deleteContentItem(item).then( + return this.notebookManager?.notebookContentClient.deleteContentItem(item).then( () => { NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully deleted: ${item.path}`); }, - reason => { + (reason: any) => { NotificationConsoleUtils.logConsoleMessage( ConsoleDataType.Error, `Failed to delete "${item.path}": ${JSON.stringify(reason)}` @@ -3072,7 +2981,7 @@ export default class Explorer implements ViewModels.Explorer { * This creates a new notebook file, then opens the notebook */ public onNewNotebookClicked(parent?: NotebookContentItem): void { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to create new notebook, but notebook is not enabled"; Logger.logError(error, "Explorer/onNewNotebookClicked"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); @@ -3092,7 +3001,7 @@ export default class Explorer implements ViewModels.Explorer { dataExplorerArea: Constants.Areas.Notebook }); - this.notebookContentClient + this.notebookManager?.notebookContentClient .createNewNotebookFile(parent) .then((newFile: NotebookContentItem) => { NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully created: ${newFile.name}`); @@ -3108,7 +3017,7 @@ export default class Explorer implements ViewModels.Explorer { return this.openNotebook(newFile); }) .then(() => this.resourceTree.triggerRender()) - .catch(reason => { + .catch((reason: any) => { const error = `Failed to create a new notebook: ${reason}`; NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); TelemetryProcessor.traceFailure( @@ -3158,14 +3067,14 @@ export default class Explorer implements ViewModels.Explorer { } public refreshContentItem(item: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookContentClient) { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to refresh notebook list, but notebook is not enabled"; Logger.logError(error, "Explorer/refreshContentItem"); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, error); return Promise.reject(new Error(error)); } - return this.notebookContentClient.updateItemChildren(item); + return this.notebookManager?.notebookContentClient.updateItemChildren(item); } public getNotebookBasePath(): string { @@ -3234,7 +3143,7 @@ export default class Explorer implements ViewModels.Explorer { newTab.onTabClick(); } - public openGallery() { + public async openGallery(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) { let title: string; let hashLocation: string; @@ -3249,25 +3158,34 @@ export default class Explorer implements ViewModels.Explorer { if (openedTabs[i].hashLocation() == hashLocation) { openedTabs[i].onTabClick(); openedTabs[i].onActivate(); + (openedTabs[i] as any).updateGalleryParams(notebookUrl, galleryItem, isFavorite); return; } } + if (!this.galleryTab) { + this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab"); + } + const newTab = new this.galleryTab.default({ + // GalleryTabOptions account: CosmosClient.databaseAccount(), + container: this, + junoClient: this.notebookManager?.junoClient, + notebookUrl, + galleryItem, + isFavorite, + // TabOptions tabKind: ViewModels.CollectionTabKind.Gallery, - node: null, title: title, tabPath: title, documentClientUtility: null, - collection: null, selfLink: null, - hashLocation: hashLocation, isActive: ko.observable(false), + hashLocation: hashLocation, + onUpdateTabsButtons: this.onUpdateTabsButtons, isTabsContentExpanded: ko.observable(true), onLoadStartKey: null, - onUpdateTabsButtons: this.onUpdateTabsButtons, - container: this, openedTabs: this.openedTabs() }); @@ -3277,16 +3195,14 @@ export default class Explorer implements ViewModels.Explorer { newTab.onTabClick(); } - public openNotebookViewer( - notebookUrl: string, - notebookMetadata: DataModels.NotebookMetadata, - onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise, - isLikedNotebook: boolean - ) { - const notebookName = path.basename(notebookUrl); - const title = notebookName; + public async openNotebookViewer(notebookUrl: string) { + const title = path.basename(notebookUrl); const hashLocation = notebookUrl; + if (!this.notebookViewerTab) { + this.notebookViewerTab = await import(/* webpackChunkName: "NotebookViewerTab" */ "./Tabs/NotebookViewerTab"); + } + const notebookViewerTabModule = this.notebookViewerTab; let isNotebookViewerOpen = (tab: ViewModels.Tab) => { @@ -3322,11 +3238,7 @@ export default class Explorer implements ViewModels.Explorer { onUpdateTabsButtons: this.onUpdateTabsButtons, container: this, openedTabs: this.openedTabs(), - notebookUrl: notebookUrl, - notebookName: notebookName, - notebookMetadata: notebookMetadata, - onNotebookMetadataChange: onNotebookMetadataChange, - isLikedNotebook: isLikedNotebook + notebookUrl }); this.openedTabs.push(newTab); diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts index 9de640c1d..6456aa7d3 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts @@ -3,6 +3,7 @@ import * as ViewModels from "../../../Contracts/ViewModels"; import { CommandBarComponentButtonFactory } from "./CommandBarComponentButtonFactory"; import { ExplorerStub } from "../../OpenActionsStubs"; import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService"; +import NotebookManager from "../../Notebook/NotebookManager"; describe("CommandBarComponentButtonFactory tests", () => { let mockExplorer: ViewModels.Explorer; @@ -19,6 +20,7 @@ describe("CommandBarComponentButtonFactory tests", () => { mockExplorer.isPreferredApiCassandra = ko.computed(() => false); mockExplorer.isSparkEnabled = ko.observable(true); mockExplorer.isGalleryEnabled = ko.computed(() => false); + mockExplorer.isGalleryPublishEnabled = ko.computed(() => false); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed(() => true); mockExplorer.isDatabaseNodeOrNoneSelected = () => true; }); @@ -81,6 +83,7 @@ describe("CommandBarComponentButtonFactory tests", () => { mockExplorer.isPreferredApiCassandra = ko.computed(() => false); mockExplorer.isSparkEnabled = ko.observable(true); mockExplorer.isGalleryEnabled = ko.computed(() => false); + mockExplorer.isGalleryPublishEnabled = ko.computed(() => false); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed(() => true); mockExplorer.isDatabaseNodeOrNoneSelected = () => true; }); @@ -161,6 +164,7 @@ describe("CommandBarComponentButtonFactory tests", () => { mockExplorer.isPreferredApiMongoDB = ko.computed(() => false); mockExplorer.isSparkEnabled = ko.observable(true); mockExplorer.isGalleryEnabled = ko.computed(() => false); + mockExplorer.isGalleryPublishEnabled = ko.computed(() => false); mockExplorer.hasAutoPilotV2FeatureFlag = ko.computed(() => true); mockExplorer.isDatabaseNodeOrNoneSelected = () => true; }); @@ -247,7 +251,9 @@ describe("CommandBarComponentButtonFactory tests", () => { mockExplorer.isNotebooksEnabledForAccount = ko.observable(false); mockExplorer.isRunningOnNationalCloud = ko.observable(false); mockExplorer.isGalleryEnabled = ko.computed(() => false); - mockExplorer.gitHubOAuthService = new GitHubOAuthService(undefined); + mockExplorer.isGalleryPublishEnabled = ko.computed(() => false); + mockExplorer.notebookManager = new NotebookManager(); + mockExplorer.notebookManager.gitHubOAuthService = new GitHubOAuthService(undefined); }); beforeEach(() => { @@ -268,7 +274,7 @@ describe("CommandBarComponentButtonFactory tests", () => { it("Notebooks is enabled and GitHubOAuthService is logged in - manage github settings button should be visible", () => { mockExplorer.isNotebookEnabled = ko.observable(true); - mockExplorer.gitHubOAuthService.isLoggedIn = jest.fn().mockReturnValue(true); + mockExplorer.notebookManager.gitHubOAuthService.isLoggedIn = jest.fn().mockReturnValue(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer); const manageGitHubSettingsBtn = buttons.find( diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts index 154d7493e..d796462ef 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts @@ -26,7 +26,6 @@ import EnableNotebooksIcon from "../../../../images/notebook/Notebook-enable.svg import NewNotebookIcon from "../../../../images/notebook/Notebook-new.svg"; import ResetWorkspaceIcon from "../../../../images/notebook/Notebook-reset-workspace.svg"; import LibraryManageIcon from "../../../../images/notebook/Spark-library-manage.svg"; -import GalleryIcon from "../../../../images/GalleryIcon.svg"; import GitHubIcon from "../../../../images/github.svg"; import SynapseIcon from "../../../../images/synapse-link.svg"; import { config, Platform } from "../../../Config"; @@ -64,7 +63,7 @@ export class CommandBarComponentButtonFactory { ]; buttons.push(newNotebookButton); - if (container.gitHubOAuthService) { + if (container.notebookManager?.gitHubOAuthService) { buttons.push(CommandBarComponentButtonFactory.createManageGitHubAccountButton(container)); } } @@ -87,10 +86,6 @@ export class CommandBarComponentButtonFactory { buttons.push(CommandBarComponentButtonFactory.createOpenTerminalButton(container)); buttons.push(CommandBarComponentButtonFactory.createNotebookWorkspaceResetButton(container)); - - if (container.isGalleryEnabled()) { - buttons.push(CommandBarComponentButtonFactory.createGalleryButton(container)); - } } // TODO: Should be replaced with the create arcadia spark pool button @@ -575,19 +570,6 @@ export class CommandBarComponentButtonFactory { }; } - private static createGalleryButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig { - const label = "View Gallery"; - return { - iconSrc: GalleryIcon, - iconAlt: label, - onCommandClick: () => container.openGallery(), - commandButtonLabel: label, - hasPopup: false, - disabled: false, - ariaLabel: label - }; - } - private static createOpenMongoTerminalButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig { const label = "Open Mongo Shell"; const tooltip = @@ -654,7 +636,7 @@ export class CommandBarComponentButtonFactory { } private static createManageGitHubAccountButton(container: ViewModels.Explorer): ViewModels.NavbarButtonConfig { - let connectedToGitHub: boolean = container.gitHubOAuthService.isLoggedIn(); + let connectedToGitHub: boolean = container.notebookManager?.gitHubOAuthService.isLoggedIn(); const label = connectedToGitHub ? "Manage GitHub settings" : "Connect to GitHub"; return { iconSrc: GitHubIcon, diff --git a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx index 7ade58739..964f636f3 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx @@ -36,11 +36,12 @@ export class CommandBarUtil { const result: ICommandBarItemProps = { iconProps: { - iconType: IconType.image, style: { - width: StyleConstants.CommandBarIconWidth // 16 + width: StyleConstants.CommandBarIconWidth, // 16 + alignSelf: btn.iconName ? "baseline" : undefined }, - imageProps: { src: btn.iconSrc, alt: btn.iconAlt } + imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined, + iconName: btn.iconName }, onClick: btn.onCommandClick, key: `${btn.commandButtonLabel}${index}`, diff --git a/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx b/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx index 64379ca3c..5933509cf 100644 --- a/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx +++ b/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx @@ -17,7 +17,7 @@ import { } from "@nteract/core"; import * as Immutable from "immutable"; import { Provider } from "react-redux"; -import { CellType, CellId } from "@nteract/commutable"; +import { CellType, CellId, toJS } from "@nteract/commutable"; import { Store, AnyAction } from "redux"; import "./NotebookComponent.less"; @@ -71,6 +71,28 @@ export class NotebookComponentBootstrapper { ); } + public getContent(): { name: string; content: string } { + const record = this.getStore() + .getState() + .core.entities.contents.byRef.get(this.contentRef); + let content: string; + switch (record.model.type) { + case "notebook": + content = JSON.stringify(toJS(record.model.notebook)); + break; + case "file": + content = record.model.text; + break; + default: + throw new Error(`Unsupported model type ${record.model.type}`); + } + + return { + name: NotebookUtil.getName(record.filepath), + content + }; + } + public setContent(name: string, content: any): void { this.getStore().dispatch( actions.fetchContentFulfilled({ diff --git a/src/Explorer/Notebook/NotebookManager.ts b/src/Explorer/Notebook/NotebookManager.ts new file mode 100644 index 000000000..73a6aab9b --- /dev/null +++ b/src/Explorer/Notebook/NotebookManager.ts @@ -0,0 +1,168 @@ +/* + * Contains all notebook related stuff meant to be dynamically loaded by explorer + */ + +import { JunoClient } from "../../Juno/JunoClient"; +import * as ViewModels from "../../Contracts/ViewModels"; +import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService"; +import { GitHubClient } from "../../GitHub/GitHubClient"; +import { config } from "../../Config"; +import * as Logger from "../../Common/Logger"; +import { HttpStatusCodes, Areas } from "../../Common/Constants"; +import { GitHubReposPane } from "../Panes/GitHubReposPane"; +import ko from "knockout"; +import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; +import { IContentProvider } from "@nteract/core"; +import { NotebookContentProvider } from "./NotebookComponent/NotebookContentProvider"; +import { GitHubContentProvider } from "../../GitHub/GitHubContentProvider"; +import { contents } from "rx-jupyter"; +import { NotebookContainerClient } from "./NotebookContainerClient"; +import { MemoryUsageInfo } from "../../Contracts/DataModels"; +import { NotebookContentClient } from "./NotebookContentClient"; +import { DialogProps } from "../Controls/DialogReactComponent/DialogComponent"; +import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; +import { PublishNotebookPaneAdapter } from "../Panes/PublishNotebookPaneAdapter"; +import { getFullName } from "../../Utils/UserUtils"; + +export interface NotebookManagerOptions { + container: ViewModels.Explorer; + notebookBasePath: ko.Observable; + dialogProps: ko.Observable; + resourceTree: ResourceTreeAdapter; + refreshCommandBarButtons: () => void; + refreshNotebookList: () => void; +} + +export default class NotebookManager { + private params: NotebookManagerOptions; + public junoClient: JunoClient; + + public notebookContentProvider: IContentProvider; + public notebookClient: ViewModels.INotebookContainerClient; + public notebookContentClient: ViewModels.INotebookContentClient; + + private gitHubContentProvider: GitHubContentProvider; + public gitHubOAuthService: GitHubOAuthService; + private gitHubClient: GitHubClient; + + public gitHubReposPane: ViewModels.ContextualPane; + public publishNotebookPaneAdapter: PublishNotebookPaneAdapter; + + public initialize(params: NotebookManagerOptions): void { + this.params = params; + this.junoClient = new JunoClient(this.params.container.databaseAccount); + + this.gitHubOAuthService = new GitHubOAuthService(this.junoClient); + this.gitHubClient = new GitHubClient(config.AZURESAMPLESCOSMOSDBPAT, this.onGitHubClientError); + this.gitHubReposPane = new GitHubReposPane({ + documentClientUtility: this.params.container.documentClientUtility, + id: "gitHubReposPane", + visible: ko.observable(false), + container: this.params.container, + junoClient: this.junoClient, + gitHubClient: this.gitHubClient + }); + + this.gitHubContentProvider = new GitHubContentProvider({ + gitHubClient: this.gitHubClient, + promptForCommitMsg: this.promptForCommitMsg + }); + + this.notebookContentProvider = new NotebookContentProvider( + this.gitHubContentProvider, + contents.JupyterContentProvider + ); + + this.notebookClient = new NotebookContainerClient( + this.params.container.notebookServerInfo, + () => this.params.container.initNotebooks(this.params.container.databaseAccount()), + (update: MemoryUsageInfo) => this.params.container.memoryUsageInfo(update) + ); + + this.notebookContentClient = new NotebookContentClient( + this.params.container.notebookServerInfo, + this.params.notebookBasePath, + this.notebookContentProvider + ); + + if (this.params.container.isGalleryPublishEnabled()) { + this.publishNotebookPaneAdapter = new PublishNotebookPaneAdapter(this.params.container, this.junoClient); + } + + this.gitHubOAuthService.getTokenObservable().subscribe(token => { + this.gitHubClient.setToken(token?.access_token ? token.access_token : config.AZURESAMPLESCOSMOSDBPAT); + + if (this.gitHubReposPane.visible()) { + this.gitHubReposPane.open(); + } + + this.params.refreshCommandBarButtons(); + this.params.refreshNotebookList(); + }); + + this.junoClient.subscribeToPinnedRepos(pinnedRepos => { + this.params.resourceTree.initializeGitHubRepos(pinnedRepos); + this.params.resourceTree.triggerRender(); + }); + this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); + } + + public openPublishNotebookPane(name: string, content: string): void { + this.publishNotebookPaneAdapter.open(name, getFullName(), content); + } + + // Octokit's error handler uses any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private onGitHubClientError = (error: any): void => { + Logger.logError(error, "NotebookManager/onGitHubClientError"); + + if (error.status === HttpStatusCodes.Unauthorized) { + this.gitHubOAuthService.resetToken(); + + this.params.container.showOkCancelModalDialog( + undefined, + "Cosmos DB cannot access your Github account anymore. Please connect to GitHub again.", + "Connect to GitHub", + () => this.gitHubReposPane.open(), + "Cancel", + undefined + ); + } + }; + + private promptForCommitMsg = (title: string, primaryButtonLabel: string) => { + return new Promise((resolve, reject) => { + let commitMsg = "Committed from Azure Cosmos DB Notebooks"; + this.params.container.showOkCancelTextFieldModalDialog( + title || "Commit", + undefined, + primaryButtonLabel || "Commit", + () => { + TelemetryProcessor.trace(Action.NotebooksGitHubCommit, ActionModifiers.Mark, { + databaseAccountName: + this.params.container.databaseAccount() && this.params.container.databaseAccount().name, + defaultExperience: this.params.container.defaultExperience && this.params.container.defaultExperience(), + dataExplorerArea: Areas.Notebook + }); + resolve(commitMsg); + }, + "Cancel", + () => reject(new Error("Commit dialog canceled")), + { + label: "Commit message", + autoAdjustHeight: true, + multiline: true, + defaultValue: commitMsg, + rows: 3, + onChange: (_, newValue: string) => { + commitMsg = newValue; + this.params.dialogProps().primaryButtonDisabled = !commitMsg; + this.params.dialogProps.valueHasMutated(); + } + }, + !commitMsg + ); + }); + }; +} diff --git a/src/Explorer/OpenActionsStubs.ts b/src/Explorer/OpenActionsStubs.ts index ce7542d17..12c237c63 100644 --- a/src/Explorer/OpenActionsStubs.ts +++ b/src/Explorer/OpenActionsStubs.ts @@ -6,8 +6,6 @@ import Q from "q"; import { ArcadiaWorkspaceItem } from "./Controls/Arcadia/ArcadiaMenuPicker"; import { CassandraTableKey, CassandraTableKeys, TableDataClient } from "../../src/Explorer/Tables/TableDataClient"; import { ConsoleData } from "../../src/Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; -import { GitHubOAuthService } from "../GitHub/GitHubOAuthService"; -import { IContentProvider } from "@nteract/core"; import { MostRecentActivity } from "./MostRecentActivity/MostRecentActivity"; import { NotebookContentItem } from "./Notebook/NotebookContentItem"; import { PlatformType } from "../../src/PlatformType"; @@ -22,6 +20,8 @@ import { UploadFilePane } from "./Panes/UploadFilePane"; import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter"; import { Versions } from "../../src/Contracts/ExplorerContracts"; import { CollectionCreationDefaults } from "../Shared/Constants"; +import { IGalleryItem } from "../Juno/JunoClient"; +import { ReactAdapter } from "../Bindings/ReactBindingHandler"; export class ExplorerStub implements ViewModels.Explorer { public flight: ko.Observable; @@ -97,7 +97,9 @@ export class ExplorerStub implements ViewModels.Explorer { public setupSparkClusterPane: ViewModels.ContextualPane; public manageSparkClusterPane: ViewModels.ContextualPane; public isGalleryEnabled: ko.Computed; + public isGalleryPublishEnabled: ko.Computed; public isGitHubPaneEnabled: ko.Observable; + public isPublishNotebookPaneEnabled: ko.Observable; public isRightPanelV2Enabled: ko.Computed; public canExceedMaximumValue: ko.Computed; public isHostedDataExplorerEnabled: ko.Computed; @@ -111,19 +113,19 @@ export class ExplorerStub implements ViewModels.Explorer { public arcadiaToken: ko.Observable; public notebookWorkspaceManager: ViewModels.NotebookWorkspaceManager; public sparkClusterManager: ViewModels.SparkClusterManager; - public notebookContentProvider: IContentProvider; - public gitHubOAuthService: GitHubOAuthService; public notebookServerInfo: ko.Observable; public sparkClusterConnectionInfo: ko.Observable; public libraryManagePane: ViewModels.ContextualPane; public clusterLibraryPane: ViewModels.ContextualPane; public gitHubReposPane: ViewModels.ContextualPane; + public publishNotebookPaneAdapter: ReactAdapter; public arcadiaWorkspaces: ko.ObservableArray; public hasStorageAnalyticsAfecFeature: ko.Observable; public isSynapseLinkUpdating: ko.Observable; public isNotebookTabActive: ko.Computed; public memoryUsageInfo: ko.Observable; - public openGallery: () => void; + public notebookManager?: any; + public openGallery: (notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) => void; public openNotebookViewer: (notebookUrl: string) => void; public resourceTokenDatabaseId: ko.Observable; public resourceTokenCollectionId: ko.Observable; @@ -331,7 +333,11 @@ export class ExplorerStub implements ViewModels.Explorer { throw new Error("Not implemented"); } - public importAndOpenFromGallery(path: string, newName: string, content: any): Promise { + public importAndOpenFromGallery(name: string, content: string): Promise { + throw new Error("Not implemented"); + } + + public publishNotebook(name: string, content: string): void { throw new Error("Not implemented"); } diff --git a/src/Explorer/Panes/GitHubReposPane.ts b/src/Explorer/Panes/GitHubReposPane.ts index 49b7134df..19920296a 100644 --- a/src/Explorer/Panes/GitHubReposPane.ts +++ b/src/Explorer/Panes/GitHubReposPane.ts @@ -132,12 +132,13 @@ export class GitHubReposPane extends ContextualPaneBase { private getOAuthScope(): string { return ( - this.container.gitHubOAuthService?.getTokenObservable()()?.scope || AuthorizeAccessComponent.Scopes.Public.key + this.container.notebookManager?.gitHubOAuthService.getTokenObservable()()?.scope || + AuthorizeAccessComponent.Scopes.Public.key ); } private setup(forceShowConnectToGitHub = false): void { - forceShowConnectToGitHub || !this.container.gitHubOAuthService.isLoggedIn() + forceShowConnectToGitHub || !this.container.notebookManager?.gitHubOAuthService.isLoggedIn() ? this.setupForConnectToGitHub() : this.setupForManageRepos(); } @@ -294,7 +295,7 @@ export class GitHubReposPane extends ContextualPaneBase { try { const response = await this.junoClient.getPinnedRepos( - this.container.gitHubOAuthService?.getTokenObservable()()?.scope + this.container.notebookManager?.gitHubOAuthService.getTokenObservable()()?.scope ); if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { throw new Error(`Received HTTP ${response.status} when fetching pinned repos`); @@ -350,7 +351,7 @@ export class GitHubReposPane extends ContextualPaneBase { dataExplorerArea: Areas.Notebook, scopesSelected: scope }); - this.container.gitHubOAuthService.startOAuth(scope); + this.container.notebookManager?.gitHubOAuthService.startOAuth(scope); } private triggerRender(): void { diff --git a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx new file mode 100644 index 000000000..e0af34ae8 --- /dev/null +++ b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx @@ -0,0 +1,164 @@ +import ko from "knockout"; +import { ITextFieldProps, Stack, Text, TextField } from "office-ui-fabric-react"; +import * as React from "react"; +import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; +import * as Logger from "../../Common/Logger"; +import * as ViewModels from "../../Contracts/ViewModels"; +import { JunoClient } from "../../Juno/JunoClient"; +import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils"; +import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; +import { FileSystemUtil } from "../Notebook/FileSystemUtil"; +import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent"; + +export class PublishNotebookPaneAdapter implements ReactAdapter { + parameters: ko.Observable; + private isOpened: boolean; + private isExecuting: boolean; + private formError: string; + private formErrorDetail: string; + + private name: string; + private author: string; + private content: string; + private description: string; + private tags: string; + private thumbnailUrl: string; + + constructor(private container: ViewModels.Explorer, private junoClient: JunoClient) { + 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: "publishnotebookpane", + isExecuting: this.isExecuting, + title: "Publish to gallery", + submitButtonText: "Publish", + onClose: () => this.close(), + onSubmit: () => this.submit() + }; + + return ; + } + + public triggerRender(): void { + window.requestAnimationFrame(() => this.parameters(Date.now())); + } + + public open(name: string, author: string, content: string): void { + this.name = name; + this.author = author; + this.content = content; + + this.isOpened = true; + this.triggerRender(); + } + + public close(): void { + this.reset(); + this.triggerRender(); + } + + public async submit(): Promise { + const notificationId = NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.InProgress, + `Publishing ${this.name} to gallery` + ); + this.isExecuting = true; + this.triggerRender(); + + try { + if (!this.name || !this.description || !this.author) { + throw new Error("Name, description, and author are required"); + } + + const response = await this.junoClient.publishNotebook( + this.name, + this.description, + this.tags?.split(","), + this.author, + this.thumbnailUrl, + this.content + ); + if (!response.data) { + throw new Error(`Received HTTP ${response.status} when publishing ${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}`; + + const message = `${this.formError}: ${this.formErrorDetail}`; + Logger.logError(message, "PublishNotebookPaneAdapter/submit"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + return; + } finally { + NotificationConsoleUtils.clearInProgressMessageWithId(notificationId); + this.isExecuting = false; + this.triggerRender(); + } + + this.close(); + } + + private createContent = (): JSX.Element => { + const descriptionPara1 = + "This notebook has your data. Please make sure you delete any sensitive data/output before publishing."; + const descriptionPara2 = `Would you like to publish and share ${FileSystemUtil.stripExtension( + this.name, + "ipynb" + )} to the gallery?`; + const descriptionProps: ITextFieldProps = { + label: "Description", + ariaLabel: "Description", + multiline: true, + rows: 3, + required: true, + onChange: (event, newValue) => (this.description = newValue) + }; + const tagsProps: ITextFieldProps = { + label: "Tags", + ariaLabel: "Tags", + placeholder: "Optional tag 1, Optional tag 2", + onChange: (event, newValue) => (this.tags = newValue) + }; + const thumbnailProps: ITextFieldProps = { + label: "Cover image url", + ariaLabel: "Cover image url", + onChange: (event, newValue) => (this.thumbnailUrl = newValue) + }; + + return ( +
+ + {descriptionPara1} + {descriptionPara2} + + + + +
+ ); + }; + + private reset = (): void => { + this.isOpened = false; + this.isExecuting = false; + this.formError = undefined; + this.formErrorDetail = undefined; + this.name = undefined; + this.author = undefined; + this.content = undefined; + }; +} diff --git a/src/Explorer/Tabs/GalleryTab.tsx b/src/Explorer/Tabs/GalleryTab.tsx index 856399b8b..8053586fb 100644 --- a/src/Explorer/Tabs/GalleryTab.tsx +++ b/src/Explorer/Tabs/GalleryTab.tsx @@ -1,45 +1,151 @@ import * as ko from "knockout"; -import * as ViewModels from "../../Contracts/ViewModels"; -import TabsBase from "./TabsBase"; import * as React from "react"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; -import { GalleryViewerContainerComponent } from "../Controls/NotebookGallery/GalleryViewerComponent"; +import * as ViewModels from "../../Contracts/ViewModels"; +import { IGalleryItem, JunoClient } from "../../Juno/JunoClient"; +import * as GalleryUtils from "../../Utils/GalleryUtils"; +import { + GalleryTab as GalleryViewerTab, + GalleryViewerComponent, + GalleryViewerComponentProps, + SortBy +} from "../Controls/NotebookGallery/GalleryViewerComponent"; +import { + NotebookViewerComponent, + NotebookViewerComponentProps +} from "../Controls/NotebookViewer/NotebookViewerComponent"; +import TabsBase from "./TabsBase"; /** * Notebook gallery tab */ +interface GalleryComponentAdapterProps { + container: ViewModels.Explorer; + junoClient: JunoClient; + notebookUrl: string; + galleryItem: IGalleryItem; + isFavorite: boolean; + selectedTab: GalleryViewerTab; + sortBy: SortBy; + searchText: string; +} + +interface GalleryComponentAdapterState { + notebookUrl: string; + galleryItem: IGalleryItem; + isFavorite: boolean; + selectedTab: GalleryViewerTab; + sortBy: SortBy; + searchText: string; +} + class GalleryComponentAdapter implements ReactAdapter { - public parameters: ko.Computed; - constructor(private getContainer: () => ViewModels.Explorer) {} + public parameters: ko.Observable; + private state: GalleryComponentAdapterState; + + constructor(private props: GalleryComponentAdapterProps) { + this.parameters = ko.observable(Date.now()); + this.state = { + notebookUrl: props.notebookUrl, + galleryItem: props.galleryItem, + isFavorite: props.isFavorite, + selectedTab: props.selectedTab, + sortBy: props.sortBy, + searchText: props.searchText + }; + } public renderComponent(): JSX.Element { - return this.parameters() ? : <>; + if (this.state.notebookUrl) { + const props: NotebookViewerComponentProps = { + container: this.props.container, + junoClient: this.props.junoClient, + notebookUrl: this.state.notebookUrl, + galleryItem: this.state.galleryItem, + isFavorite: this.state.isFavorite, + backNavigationText: GalleryUtils.getTabTitle(this.state.selectedTab), + onBackClick: this.onBackClick, + onTagClick: this.loadTaggedItems + }; + + return ; + } + + const props: GalleryViewerComponentProps = { + container: this.props.container, + junoClient: this.props.junoClient, + selectedTab: this.state.selectedTab, + sortBy: this.state.sortBy, + searchText: this.state.searchText, + onSelectedTabChange: this.onSelectedTabChange, + onSortByChange: this.onSortByChange, + onSearchTextChange: this.onSearchTextChange + }; + + return ; } + + public setState(state: Partial): void { + this.state = Object.assign(this.state, state); + window.requestAnimationFrame(() => this.parameters(Date.now())); + } + + private onBackClick = (): void => { + this.props.container.openGallery(); + }; + + private loadTaggedItems = (tag: string): void => { + this.setState({ + notebookUrl: undefined, + searchText: tag + }); + }; + + private onSelectedTabChange = (selectedTab: GalleryViewerTab): void => { + this.state.selectedTab = selectedTab; + }; + + private onSortByChange = (sortBy: SortBy): void => { + this.state.sortBy = sortBy; + }; + + private onSearchTextChange = (searchText: string): void => { + this.state.searchText = searchText; + }; } export default class GalleryTab extends TabsBase implements ViewModels.Tab { private container: ViewModels.Explorer; + private galleryComponentAdapterProps: GalleryComponentAdapterProps; private galleryComponentAdapter: GalleryComponentAdapter; constructor(options: ViewModels.GalleryTabOptions) { super(options); - this.container = options.container; - this.galleryComponentAdapter = new GalleryComponentAdapter(() => this.getContainer()); - this.galleryComponentAdapter.parameters = ko.computed(() => { - return this.isTemplateReady() && this.container.isNotebookEnabled(); - }); + this.container = options.container; + this.galleryComponentAdapterProps = { + container: options.container, + junoClient: options.junoClient, + notebookUrl: options.notebookUrl, + galleryItem: options.galleryItem, + isFavorite: options.isFavorite, + selectedTab: GalleryViewerTab.OfficialSamples, + sortBy: SortBy.MostViewed, + searchText: undefined + }; + + this.galleryComponentAdapter = new GalleryComponentAdapter(this.galleryComponentAdapterProps); } protected getContainer(): ViewModels.Explorer { return this.container; } - protected getTabsButtons(): ViewModels.NavbarButtonConfig[] { - return []; - } - - protected buildCommandBarOptions(): void { - this.updateNavbarWithTabsButtons(); + public updateGalleryParams(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean): void { + this.galleryComponentAdapter.setState({ + notebookUrl, + galleryItem, + isFavorite + }); } } diff --git a/src/Explorer/Tabs/NotebookV2Tab.ts b/src/Explorer/Tabs/NotebookV2Tab.ts index 659ec1264..5d4ab0602 100644 --- a/src/Explorer/Tabs/NotebookV2Tab.ts +++ b/src/Explorer/Tabs/NotebookV2Tab.ts @@ -45,7 +45,7 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab { connectionInfo: this.container.notebookServerInfo(), databaseAccountName: this.container.databaseAccount().name, defaultExperience: this.container.defaultExperience(), - contentProvider: this.container.notebookContentProvider + contentProvider: this.container.notebookManager?.notebookContentProvider }); } @@ -112,6 +112,7 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab { const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs(); const saveLabel = "Save"; + const publishLabel = "Publish to gallery"; const workspaceLabel = "No Workspace"; const kernelLabel = "No Kernel"; const runLabel = "Run"; @@ -142,7 +143,27 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab { commandButtonLabel: saveLabel, hasPopup: false, disabled: false, - ariaLabel: saveLabel + ariaLabel: saveLabel, + children: this.container.isGalleryPublishEnabled() + ? [ + { + iconName: "Save", + onCommandClick: () => this.notebookComponentAdapter.notebookSave(), + commandButtonLabel: saveLabel, + hasPopup: false, + disabled: false, + ariaLabel: saveLabel + }, + { + iconName: "PublishContent", + onCommandClick: () => this.publishToGallery(), + commandButtonLabel: publishLabel, + hasPopup: false, + disabled: false, + ariaLabel: publishLabel + } + ] + : undefined }, { iconSrc: null, @@ -425,6 +446,11 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab { ); } + private publishToGallery = () => { + const notebookContent = this.notebookComponentAdapter.getContent(); + this.container.publishNotebook(notebookContent.name, notebookContent.content); + }; + private traceTelemetry(actionType: number) { TelemetryProcessor.trace(actionType, ActionModifiers.Mark, { databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name, diff --git a/src/Explorer/Tabs/NotebookViewerTab.tsx b/src/Explorer/Tabs/NotebookViewerTab.tsx index df2dffe0f..ee9b31089 100644 --- a/src/Explorer/Tabs/NotebookViewerTab.tsx +++ b/src/Explorer/Tabs/NotebookViewerTab.tsx @@ -1,10 +1,12 @@ import * as ko from "knockout"; -import * as ViewModels from "../../Contracts/ViewModels"; -import * as DataModels from "../../Contracts/DataModels"; -import TabsBase from "./TabsBase"; import * as React from "react"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; -import { NotebookViewerComponent } from "../Controls/NotebookViewer/NotebookViewerComponent"; +import * as ViewModels from "../../Contracts/ViewModels"; +import { + NotebookViewerComponent, + NotebookViewerComponentProps +} from "../Controls/NotebookViewer/NotebookViewerComponent"; +import TabsBase from "./TabsBase"; /** * Notebook Viewer tab @@ -12,48 +14,32 @@ import { NotebookViewerComponent } from "../Controls/NotebookViewer/NotebookView class NotebookViewerComponentAdapter implements ReactAdapter { // parameters: true: show, false: hide public parameters: ko.Computed; - constructor( - private notebookUrl: string, - private notebookName: string, - private container: ViewModels.Explorer, - private notebookMetadata: DataModels.NotebookMetadata, - private onNotebookMetadataChange: (newNotebookMetadata: DataModels.NotebookMetadata) => Promise, - private isLikedNotebook: boolean - ) {} + constructor(private notebookUrl: string) {} public renderComponent(): JSX.Element { - return this.parameters() ? ( - - ) : ( - <> - ); + const props: NotebookViewerComponentProps = { + notebookUrl: this.notebookUrl, + backNavigationText: undefined, + onBackClick: undefined, + onTagClick: undefined + }; + + return this.parameters() ? : <>; } } export default class NotebookViewerTab extends TabsBase implements ViewModels.Tab { private container: ViewModels.Explorer; - public notebookViewerComponentAdapter: NotebookViewerComponentAdapter; public notebookUrl: string; + public notebookViewerComponentAdapter: NotebookViewerComponentAdapter; + constructor(options: ViewModels.NotebookViewerTabOptions) { super(options); this.container = options.container; this.notebookUrl = options.notebookUrl; - this.notebookViewerComponentAdapter = new NotebookViewerComponentAdapter( - options.notebookUrl, - options.notebookName, - options.container, - options.notebookMetadata, - options.onNotebookMetadataChange, - options.isLikedNotebook - ); + + this.notebookViewerComponentAdapter = new NotebookViewerComponentAdapter(options.notebookUrl); this.notebookViewerComponentAdapter.parameters = ko.computed(() => { if (this.isTemplateReady() && this.container.isNotebookEnabled()) { diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 076e8847e..5c9f42b79 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -19,12 +19,15 @@ import { ArrayHashMap } from "../../Common/ArrayHashMap"; import { NotebookUtil } from "../Notebook/NotebookUtil"; import _ from "underscore"; import { StringUtils } from "../../Utils/StringUtils"; -import { JunoClient, IPinnedRepo } from "../../Juno/JunoClient"; +import { IPinnedRepo } from "../../Juno/JunoClient"; import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import { Areas } from "../../Common/Constants"; import * as GitHubUtils from "../../Utils/GitHubUtils"; import { SamplesRepo, SamplesBranch } from "../Notebook/NotebookSamples"; +import GalleryIcon from "../../../images/GalleryIcon.svg"; +import { Callout, Text, Link, DirectionalHint, Stack, ICalloutProps, ILinkProps } from "office-ui-fabric-react"; +import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility"; export class ResourceTreeAdapter implements ReactAdapter { private static readonly DataTitle = "DATA"; @@ -33,17 +36,16 @@ export class ResourceTreeAdapter implements ReactAdapter { public parameters: ko.Observable; + public galleryContentRoot: NotebookContentItem; public sampleNotebooksContentRoot: NotebookContentItem; public myNotebooksContentRoot: NotebookContentItem; public gitHubNotebooksContentRoot: NotebookContentItem; - private pinnedReposSubscription: ko.Subscription; - private koSubsDatabaseIdMap: ArrayHashMap; // database id -> ko subs private koSubsCollectionIdMap: ArrayHashMap; // collection id -> ko subs private databaseCollectionIdMap: ArrayHashMap; // database id -> collection ids - public constructor(private container: ViewModels.Explorer, private junoClient: JunoClient) { + public constructor(private container: ViewModels.Explorer) { this.parameters = ko.observable(Date.now()); this.container.selectedNode.subscribe((newValue: any) => this.triggerRender()); @@ -72,14 +74,18 @@ export class ResourceTreeAdapter implements ReactAdapter { if (this.container.isNotebookEnabled()) { return ( - - - - - - - - + <> + + + + + + + + + + {this.galleryContentRoot && this.buildGalleryCallout()} + ); } else { return ; @@ -89,14 +95,26 @@ export class ResourceTreeAdapter implements ReactAdapter { public async initialize(): Promise { const refreshTasks: Promise[] = []; - this.sampleNotebooksContentRoot = { - name: "Sample Notebooks (View Only)", - path: GitHubUtils.toContentUri(SamplesRepo.owner, SamplesRepo.name, SamplesBranch.name, ""), - type: NotebookContentItemType.Directory - }; - refreshTasks.push( - this.container.refreshContentItem(this.sampleNotebooksContentRoot).then(() => this.triggerRender()) - ); + if (this.container.isGalleryEnabled()) { + this.galleryContentRoot = { + name: "Gallery", + path: "Gallery", + type: NotebookContentItemType.File + }; + + this.sampleNotebooksContentRoot = undefined; + } else { + this.galleryContentRoot = undefined; + + this.sampleNotebooksContentRoot = { + name: "Sample Notebooks (View Only)", + path: GitHubUtils.toContentUri(SamplesRepo.owner, SamplesRepo.name, SamplesBranch.name, ""), + type: NotebookContentItemType.Directory + }; + refreshTasks.push( + this.container.refreshContentItem(this.sampleNotebooksContentRoot).then(() => this.triggerRender()) + ); + } this.myNotebooksContentRoot = { name: "My Notebooks", @@ -111,14 +129,12 @@ export class ResourceTreeAdapter implements ReactAdapter { ); } - if (this.container.gitHubOAuthService?.isLoggedIn()) { + if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { this.gitHubNotebooksContentRoot = { name: "GitHub repos", path: ResourceTreeAdapter.PseudoDirPath, type: NotebookContentItemType.Directory }; - - refreshTasks.push(this.refreshGitHubReposAndTriggerRender(this.gitHubNotebooksContentRoot)); } else { this.gitHubNotebooksContentRoot = undefined; } @@ -126,10 +142,10 @@ export class ResourceTreeAdapter implements ReactAdapter { return Promise.all(refreshTasks); } - private async refreshGitHubReposAndTriggerRender(item: NotebookContentItem): Promise { - const updateGitHubReposAndRender = (pinnedRepos: IPinnedRepo[]) => { - item.children = []; - pinnedRepos.forEach(pinnedRepo => { + public initializeGitHubRepos(pinnedRepos: IPinnedRepo[]): void { + if (this.gitHubNotebooksContentRoot) { + this.gitHubNotebooksContentRoot.children = []; + pinnedRepos?.forEach(pinnedRepo => { const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name); const repoTreeItem: NotebookContentItem = { name: repoFullName, @@ -146,20 +162,11 @@ export class ResourceTreeAdapter implements ReactAdapter { }); }); - item.children.push(repoTreeItem); + this.gitHubNotebooksContentRoot.children.push(repoTreeItem); }); this.triggerRender(); - }; - - if (this.pinnedReposSubscription) { - this.pinnedReposSubscription.dispose(); } - this.pinnedReposSubscription = this.junoClient.subscribeToPinnedRepos(pinnedRepos => - updateGitHubReposAndRender(pinnedRepos) - ); - - await this.junoClient.getPinnedRepos(this.container.gitHubOAuthService?.getTokenObservable()()?.scope); } private buildDataTree(): TreeNode { @@ -347,10 +354,13 @@ export class ResourceTreeAdapter implements ReactAdapter { let notebooksTree: TreeNode = { label: undefined, isExpanded: true, - isLeavesParentsSeparate: true, children: [] }; + if (this.galleryContentRoot) { + notebooksTree.children.push(this.buildGalleryNotebooksTree()); + } + if (this.sampleNotebooksContentRoot) { notebooksTree.children.push(this.buildSampleNotebooksTree()); } @@ -368,6 +378,65 @@ export class ResourceTreeAdapter implements ReactAdapter { return notebooksTree; } + private buildGalleryCallout(): JSX.Element { + if ( + LocalStorageUtility.hasItem(StorageKey.GalleryCalloutDismissed) && + LocalStorageUtility.getEntryBoolean(StorageKey.GalleryCalloutDismissed) + ) { + return undefined; + } + + const calloutProps: ICalloutProps = { + calloutMaxWidth: 350, + ariaLabel: "New gallery", + role: "alertdialog", + gapSpace: 0, + target: ".galleryHeader", + directionalHint: DirectionalHint.leftTopEdge, + onDismiss: () => { + LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true); + this.triggerRender(); + }, + setInitialFocus: true + }; + + const openGalleryProps: ILinkProps = { + onClick: () => { + LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true); + this.container.openGallery(); + this.triggerRender(); + } + }; + + return ( + + + + New gallery + + + Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other + contributors. + + Open gallery + + + ); + } + + private buildGalleryNotebooksTree(): TreeNode { + return { + label: "Gallery", + iconSrc: GalleryIcon, + className: "notebookHeader galleryHeader", + onClick: () => this.container.openGallery(), + isSelected: () => { + const activeTab = this.container.findActiveTab(); + return activeTab && activeTab.tabKind === ViewModels.CollectionTabKind.Gallery; + } + }; + } + private buildSampleNotebooksTree(): TreeNode { const sampleNotebooksTree: TreeNode = this.buildNotebookDirectoryNode( this.sampleNotebooksContentRoot, @@ -467,7 +536,7 @@ export class ResourceTreeAdapter implements ReactAdapter { defaultExperience: this.container.defaultExperience && this.container.defaultExperience(), dataExplorerArea: Areas.Notebook }); - this.container.gitHubOAuthService.logout(); + this.container.notebookManager?.gitHubOAuthService.logout(); } } ]; diff --git a/src/GalleryViewer/GalleryViewer.tsx b/src/GalleryViewer/GalleryViewer.tsx index dad7f937a..d1c67e5f3 100644 --- a/src/GalleryViewer/GalleryViewer.tsx +++ b/src/GalleryViewer/GalleryViewer.tsx @@ -1,19 +1,33 @@ -import * as ReactDOM from "react-dom"; import "bootstrap/dist/css/bootstrap.css"; -import { CosmosClient } from "../Common/CosmosClient"; -import { GalleryViewerComponent } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent"; -import { JunoUtils } from "../Utils/JunoUtils"; import { initializeIcons } from "office-ui-fabric-react/lib/Icons"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { initializeConfiguration } from "../Config"; +import { + GalleryTab, + GalleryViewerComponent, + GalleryViewerComponentProps, + SortBy +} from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent"; +import { JunoClient } from "../Juno/JunoClient"; +import * as GalleryUtils from "../Utils/GalleryUtils"; const onInit = async () => { initializeIcons(); - const officialSamplesData = await JunoUtils.getOfficialSampleNotebooks(CosmosClient.authorizationToken()); - const galleryViewerComponent = new GalleryViewerComponent({ - officialSamplesData: officialSamplesData, - likedNotebookData: undefined, - container: undefined - }); - ReactDOM.render(galleryViewerComponent.render(), document.getElementById("galleryContent")); + await initializeConfiguration(); + const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window); + + const props: GalleryViewerComponentProps = { + junoClient: new JunoClient(), + selectedTab: galleryViewerProps.selectedTab || GalleryTab.OfficialSamples, + sortBy: galleryViewerProps.sortBy || SortBy.MostViewed, + searchText: galleryViewerProps.searchText, + onSelectedTabChange: undefined, + onSortByChange: undefined, + onSearchTextChange: undefined + }; + + ReactDOM.render(, document.getElementById("galleryContent")); }; // Entry point diff --git a/src/GalleryViewer/galleryViewer.html b/src/GalleryViewer/galleryViewer.html index bbb26d4ff..ab12ad36a 100644 --- a/src/GalleryViewer/galleryViewer.html +++ b/src/GalleryViewer/galleryViewer.html @@ -8,6 +8,6 @@ -
+
diff --git a/src/GitHub/GitHubOAuthService.test.ts b/src/GitHub/GitHubOAuthService.test.ts index 9ff5cca4b..2327dc18f 100644 --- a/src/GitHub/GitHubOAuthService.test.ts +++ b/src/GitHub/GitHubOAuthService.test.ts @@ -5,6 +5,7 @@ import { JunoClient } from "../Juno/JunoClient"; import { GitHubConnector, IGitHubConnectorParams } from "./GitHubConnector"; import { GitHubOAuthService } from "./GitHubOAuthService"; import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; +import NotebookManager from "../Explorer/Notebook/NotebookManager"; const sampleDatabaseAccount: ViewModels.DatabaseAccount = { id: "id", @@ -32,10 +33,12 @@ describe("GitHubOAuthService", () => { originalDataExplorer = window.dataExplorer; window.dataExplorer = { ...originalDataExplorer, - gitHubOAuthService, logConsoleData: (data): void => data.type === ConsoleDataType.Error ? console.error(data.message) : console.log(data.message) } as ViewModels.Explorer; + window.dataExplorer.notebookManager = new NotebookManager(); + window.dataExplorer.notebookManager.junoClient = junoClient; + window.dataExplorer.notebookManager.gitHubOAuthService = gitHubOAuthService; }); afterEach(() => { diff --git a/src/GitHub/GitHubOAuthService.ts b/src/GitHub/GitHubOAuthService.ts index 5c20bec71..680d33e67 100644 --- a/src/GitHub/GitHubOAuthService.ts +++ b/src/GitHub/GitHubOAuthService.ts @@ -17,7 +17,7 @@ window.addEventListener("message", (event: MessageEvent) => { const msg = event.data; if (msg.type === GitHubConnectorMsgType) { const params = msg.data as IGitHubConnectorParams; - window.dataExplorer.gitHubOAuthService.finishOAuth(params); + window.dataExplorer.notebookManager?.gitHubOAuthService.finishOAuth(params); } }); diff --git a/src/Juno/JunoClient.ts b/src/Juno/JunoClient.ts index e0f581f0c..ca60af888 100644 --- a/src/Juno/JunoClient.ts +++ b/src/Juno/JunoClient.ts @@ -2,10 +2,10 @@ import ko from "knockout"; import { HttpStatusCodes } from "../Common/Constants"; import { config } from "../Config"; import * as ViewModels from "../Contracts/ViewModels"; +import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent"; import { IGitHubResponse } from "../GitHub/GitHubClient"; import { IGitHubOAuthToken } from "../GitHub/GitHubOAuthService"; import { getAuthorizationHeader } from "../Utils/AuthorizationUtils"; -import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent"; export interface IJunoResponse { status: number; @@ -23,10 +23,39 @@ export interface IPinnedBranch { name: string; } +export interface IGalleryItem { + id: string; + name: string; + description: string; + gitSha: string; + tags: string[]; + author: string; + thumbnailUrl: string; + created: string; + isSample: boolean; + downloads: number; + favorites: number; + views: number; +} + +export interface IUserGallery { + favorites: string[]; + published: string[]; +} + +interface IPublishNotebookRequest { + name: string; + description: string; + tags: string[]; + author: string; + thumbnailUrl: string; + content: any; +} + export class JunoClient { private cachedPinnedRepos: ko.Observable; - constructor(public databaseAccount: ko.Observable) { + constructor(private databaseAccount?: ko.Observable) { this.cachedPinnedRepos = ko.observable([]); } @@ -35,8 +64,8 @@ export class JunoClient { } public async getPinnedRepos(scope: string): Promise> { - const response = await window.fetch(`${this.getJunoGitHubUrl()}/pinnedrepos`, { - headers: this.getHeaders() + const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/pinnedrepos`, { + headers: JunoClient.getHeaders() }); let pinnedRepos: IPinnedRepo[]; @@ -58,10 +87,10 @@ export class JunoClient { } public async updatePinnedRepos(repos: IPinnedRepo[]): Promise> { - const response = await window.fetch(`${this.getJunoGitHubUrl()}/pinnedrepos`, { + const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/pinnedrepos`, { method: "PUT", body: JSON.stringify(repos), - headers: this.getHeaders() + headers: JunoClient.getHeaders() }); if (response.status === HttpStatusCodes.OK) { @@ -75,9 +104,9 @@ export class JunoClient { } public async deleteGitHubInfo(): Promise> { - const response = await window.fetch(this.getJunoGitHubUrl(), { + const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github`, { method: "DELETE", - headers: this.getHeaders() + headers: JunoClient.getHeaders() }); return { @@ -90,8 +119,8 @@ export class JunoClient { const githubParams = JunoClient.getGitHubClientParams(); githubParams.append("code", code); - const response = await window.fetch(`${this.getJunoGitHubUrl()}/token?${githubParams.toString()}`, { - headers: this.getHeaders() + const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, { + headers: JunoClient.getHeaders() }); let data: IGitHubOAuthToken; @@ -114,9 +143,9 @@ export class JunoClient { const githubParams = JunoClient.getGitHubClientParams(); githubParams.append("access_token", token); - const response = await window.fetch(`${this.getJunoGitHubUrl()}/token?${githubParams.toString()}`, { + const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, { method: "DELETE", - headers: this.getHeaders() + headers: JunoClient.getHeaders() }); return { @@ -125,11 +154,201 @@ export class JunoClient { }; } - private getJunoGitHubUrl(): string { - return `${config.JUNO_ENDPOINT}/api/notebooks/${this.databaseAccount().name}/github`; + public async getSampleNotebooks(): Promise> { + return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/samples`); } - private getHeaders(): HeadersInit { + public async getPublicNotebooks(): Promise> { + return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`); + } + + public async getNotebook(id: string): Promise> { + const response = await window.fetch(this.getNotebookInfoUrl(id)); + + let data: IGalleryItem; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public async getNotebookContent(id: string): Promise> { + const response = await window.fetch(this.getNotebookContentUrl(id)); + + let data: string; + if (response.status === HttpStatusCodes.OK) { + data = await response.text(); + } + + return { + status: response.status, + data + }; + } + + public async increaseNotebookViews(id: string): Promise> { + const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/views`, { + method: "PATCH" + }); + + let data: IGalleryItem; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public async increaseNotebookDownloadCount(id: string): Promise> { + const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/downloads`, { + method: "PATCH", + headers: JunoClient.getHeaders() + }); + + let data: IGalleryItem; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public async favoriteNotebook(id: string): Promise> { + const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/favorite`, { + method: "PATCH", + headers: JunoClient.getHeaders() + }); + + let data: IGalleryItem; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public async unfavoriteNotebook(id: string): Promise> { + const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}/unfavorite`, { + method: "PATCH", + headers: JunoClient.getHeaders() + }); + + let data: IGalleryItem; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public async getFavoriteNotebooks(): Promise> { + return await this.getNotebooks(`${this.getNotebooksUrl()}/gallery/favorites`, { + headers: JunoClient.getHeaders() + }); + } + + public async getPublishedNotebooks(): Promise> { + return await this.getNotebooks(`${this.getNotebooksUrl()}/gallery/published`, { + headers: JunoClient.getHeaders() + }); + } + + public async deleteNotebook(id: string): Promise> { + const response = await window.fetch(`${this.getNotebooksUrl()}/gallery/${id}`, { + method: "DELETE", + headers: JunoClient.getHeaders() + }); + + let data: IGalleryItem; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public async publishNotebook( + name: string, + description: string, + tags: string[], + author: string, + thumbnailUrl: string, + content: string + ): 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) + }); + + let data: IGalleryItem; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + public getNotebookContentUrl(id: string): string { + return `${this.getNotebooksUrl()}/gallery/${id}/content`; + } + + public getNotebookInfoUrl(id: string): string { + return `${this.getNotebooksUrl()}/gallery/${id}`; + } + + private async getNotebooks(input: RequestInfo, init?: RequestInit): Promise> { + const response = await window.fetch(input, init); + + let data: IGalleryItem[]; + if (response.status === HttpStatusCodes.OK) { + data = await response.json(); + } + + return { + status: response.status, + data + }; + } + + private getNotebooksUrl(): string { + return `${config.JUNO_ENDPOINT}/api/notebooks`; + } + + private getNotebooksAccountUrl(): string { + return `${config.JUNO_ENDPOINT}/api/notebooks/${this.databaseAccount().name}`; + } + + private static getHeaders(): HeadersInit { const authorizationHeader = getAuthorizationHeader(); return { [authorizationHeader.header]: authorizationHeader.token, @@ -137,7 +356,7 @@ export class JunoClient { }; } - public static getGitHubClientParams(): URLSearchParams { + private static getGitHubClientParams(): URLSearchParams { const githubParams = new URLSearchParams({ client_id: config.GITHUB_CLIENT_ID }); diff --git a/src/NotebookViewer/NotebookViewer.tsx b/src/NotebookViewer/NotebookViewer.tsx index 7fbcbdddd..68e9dc443 100644 --- a/src/NotebookViewer/NotebookViewer.tsx +++ b/src/NotebookViewer/NotebookViewer.tsx @@ -1,42 +1,44 @@ +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 "bootstrap/dist/css/bootstrap.css"; -import { NotebookMetadata } from "../Contracts/DataModels"; -import { NotebookViewerComponent } from "../Explorer/Controls/NotebookViewer/NotebookViewerComponent"; -import { SessionStorageUtility, StorageKey } from "../Shared/StorageUtility"; - -const getNotebookUrl = (): string => { - const regex: RegExp = new RegExp("[?&]notebookurl=([^&#]*)|&|#|$"); - const results: RegExpExecArray | null = regex.exec(window.location.href); - if (!results || !results[1]) { - return ""; - } - - return decodeURIComponent(results[1]); -}; +import { initializeConfiguration } from "../Config"; +import { + NotebookViewerComponent, + NotebookViewerComponentProps +} from "../Explorer/Controls/NotebookViewer/NotebookViewerComponent"; +import { IGalleryItem, JunoClient } from "../Juno/JunoClient"; +import * as GalleryUtils from "../Utils/GalleryUtils"; const onInit = async () => { - var notebookMetadata: NotebookMetadata; - const notebookMetadataString = SessionStorageUtility.getEntryString(StorageKey.NotebookMetadata); - const notebookName = SessionStorageUtility.getEntryString(StorageKey.NotebookName); + initializeIcons(); + await initializeConfiguration(); + const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window); + const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window); + const backNavigationText = galleryViewerProps.selectedTab && GalleryUtils.getTabTitle(galleryViewerProps.selectedTab); - if (notebookMetadataString == "null" || notebookMetadataString != null) { - notebookMetadata = (await JSON.parse(notebookMetadataString)) as NotebookMetadata; - SessionStorageUtility.removeEntry(StorageKey.NotebookMetadata); - SessionStorageUtility.removeEntry(StorageKey.NotebookName); + const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl); + render(notebookUrl, backNavigationText); + + const galleryItemId = notebookViewerProps.galleryItemId; + if (galleryItemId) { + const junoClient = new JunoClient(); + const notebook = await junoClient.getNotebook(galleryItemId); + render(notebookUrl, backNavigationText, notebook.data); } +}; - const urlParams = new URLSearchParams(window.location.search); +const render = (notebookUrl: string, backNavigationText: string, galleryItem?: IGalleryItem) => { + const props: NotebookViewerComponentProps = { + junoClient: galleryItem ? new JunoClient() : undefined, + notebookUrl, + galleryItem, + backNavigationText, + onBackClick: undefined, + onTagClick: undefined + }; - const notebookViewerComponent = ( - - ); - ReactDOM.render(notebookViewerComponent, document.getElementById("notebookContent")); + ReactDOM.render(, document.getElementById("notebookContent")); }; // Entry point diff --git a/src/Shared/StorageUtility.ts b/src/Shared/StorageUtility.ts index 257fbb531..bc3de00eb 100644 --- a/src/Shared/StorageUtility.ts +++ b/src/Shared/StorageUtility.ts @@ -71,6 +71,5 @@ export enum StorageKey { TenantId, MostRecentActivity, SetPartitionKeyUndefined, - NotebookMetadata, - NotebookName + GalleryCalloutDismissed } diff --git a/src/Utils/GalleryUtils.ts b/src/Utils/GalleryUtils.ts new file mode 100644 index 000000000..7bc954466 --- /dev/null +++ b/src/Utils/GalleryUtils.ts @@ -0,0 +1,260 @@ +import { LinkProps, DialogProps } from "../Explorer/Controls/DialogReactComponent/DialogComponent"; +import { IGalleryItem, JunoClient } from "../Juno/JunoClient"; +import * as ViewModels from "../Contracts/ViewModels"; +import { NotificationConsoleUtils } from "./NotificationConsoleUtils"; +import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; +import * as Logger from "../Common/Logger"; +import { + GalleryTab, + SortBy, + GalleryViewerComponent +} from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent"; + +export interface DialogEnabledComponent { + setDialogProps: (dialogProps: DialogProps) => void; +} + +export enum NotebookViewerParams { + NotebookUrl = "notebookUrl", + GalleryItemId = "galleryItemId" +} + +export interface NotebookViewerProps { + notebookUrl: string; + galleryItemId: string; +} + +export enum GalleryViewerParams { + SelectedTab = "tab", + SortBy = "sort", + SearchText = "q" +} + +export interface GalleryViewerProps { + selectedTab: GalleryTab; + sortBy: SortBy; + searchText: string; +} + +export function showOkCancelModalDialog( + component: DialogEnabledComponent, + title: string, + msg: string, + linkProps: LinkProps, + okLabel: string, + onOk: () => void, + cancelLabel: string, + onCancel: () => void +): void { + component.setDialogProps({ + linkProps, + isModal: true, + visible: true, + title, + subText: msg, + primaryButtonText: okLabel, + secondaryButtonText: cancelLabel, + onPrimaryButtonClick: () => { + component.setDialogProps(undefined); + onOk && onOk(); + }, + onSecondaryButtonClick: () => { + component.setDialogProps(undefined); + onCancel && onCancel(); + } + }); +} + +export function downloadItem( + component: DialogEnabledComponent, + container: ViewModels.Explorer, + junoClient: JunoClient, + data: IGalleryItem, + onComplete: (item: IGalleryItem) => void +): void { + const name = data.name; + + if (container) { + container.showOkCancelModalDialog( + "Download to My Notebooks", + `Download ${name} from gallery as a copy to your notebooks to run and/or edit the notebook.`, + "Download", + async () => { + const notificationId = NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.InProgress, + `Downloading ${name} to My Notebooks` + ); + + try { + const response = await junoClient.getNotebookContent(data.id); + if (!response.data) { + throw new Error(`Received HTTP ${response.status} when fetching ${data.name}`); + } + + await container.importAndOpenFromGallery(data.name, response.data); + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Info, + `Successfully downloaded ${name} to My Notebooks` + ); + + const increaseDownloadResponse = await junoClient.increaseNotebookDownloadCount(data.id); + if (increaseDownloadResponse.data) { + onComplete(increaseDownloadResponse.data); + } + } catch (error) { + const message = `Failed to download ${data.name}: ${error}`; + Logger.logError(message, "GalleryUtils/downloadItem"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + } + + NotificationConsoleUtils.clearInProgressMessageWithId(notificationId); + }, + "Cancel", + undefined + ); + } else { + showOkCancelModalDialog( + component, + "Edit/Run notebook in Cosmos DB data explorer", + `In order to edit/run ${name} in Cosmos DB data explorer, a Cosmos DB account will be needed. If you do not have a Cosmos DB account yet, please create one.`, + { + linkText: "Learn more about Cosmos DB", + linkUrl: "https://azure.microsoft.com/en-us/services/cosmos-db" + }, + "Open data explorer", + () => { + window.open("https://cosmos.azure.com"); + }, + "Create Cosmos DB account", + () => { + window.open("https://ms.portal.azure.com/#create/Microsoft.DocumentDB"); + } + ); + } +} + +export async function favoriteItem( + container: ViewModels.Explorer, + junoClient: JunoClient, + data: IGalleryItem, + onComplete: (item: IGalleryItem) => void +): Promise { + if (container) { + try { + const response = await junoClient.favoriteNotebook(data.id); + if (!response.data) { + throw new Error(`Received HTTP ${response.status} when favoriting ${data.name}`); + } + + onComplete(response.data); + } catch (error) { + const message = `Failed to favorite ${data.name}: ${error}`; + Logger.logError(message, "GalleryUtils/favoriteItem"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + } + } +} + +export async function unfavoriteItem( + container: ViewModels.Explorer, + junoClient: JunoClient, + data: IGalleryItem, + onComplete: (item: IGalleryItem) => void +): Promise { + if (container) { + try { + const response = await junoClient.unfavoriteNotebook(data.id); + if (!response.data) { + throw new Error(`Received HTTP ${response.status} when unfavoriting ${data.name}`); + } + + onComplete(response.data); + } catch (error) { + const message = `Failed to unfavorite ${data.name}: ${error}`; + Logger.logError(message, "GalleryUtils/unfavoriteItem"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + } + } +} + +export function deleteItem( + container: ViewModels.Explorer, + junoClient: JunoClient, + data: IGalleryItem, + onComplete: (item: IGalleryItem) => void +): void { + if (container) { + container.showOkCancelModalDialog( + "Remove published notebook", + `Would you like to remove ${data.name} from the gallery?`, + "Remove", + async () => { + const name = data.name; + const notificationId = NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.InProgress, + `Removing ${name} from gallery` + ); + + try { + const response = await junoClient.deleteNotebook(data.id); + if (!response.data) { + throw new Error(`Received HTTP ${response.status} while removing ${name}`); + } + + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully removed ${name} from gallery`); + onComplete(response.data); + } catch (error) { + const message = `Failed to remove ${name} from gallery: ${error}`; + Logger.logError(message, "GalleryUtils/deleteItem"); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); + } + + NotificationConsoleUtils.clearInProgressMessageWithId(notificationId); + }, + "Cancel", + undefined + ); + } +} + +export function getGalleryViewerProps(window: Window & typeof globalThis): GalleryViewerProps { + const params = new URLSearchParams(window.location.search); + let selectedTab: GalleryTab; + if (params.has(GalleryViewerParams.SelectedTab)) { + selectedTab = GalleryTab[params.get(GalleryViewerParams.SelectedTab) as keyof typeof GalleryTab]; + } + + let sortBy: SortBy; + if (params.has(GalleryViewerParams.SortBy)) { + sortBy = SortBy[params.get(GalleryViewerParams.SortBy) as keyof typeof SortBy]; + } + + return { + selectedTab, + sortBy, + searchText: params.get(GalleryViewerParams.SearchText) + }; +} + +export function getNotebookViewerProps(window: Window & typeof globalThis): NotebookViewerProps { + const params = new URLSearchParams(window.location.search); + return { + notebookUrl: params.get(NotebookViewerParams.NotebookUrl), + galleryItemId: params.get(NotebookViewerParams.GalleryItemId) + }; +} + +export function getTabTitle(tab: GalleryTab): string { + switch (tab) { + case GalleryTab.OfficialSamples: + return GalleryViewerComponent.OfficialSamplesTitle; + case GalleryTab.PublicGallery: + return GalleryViewerComponent.PublicGalleryTitle; + case GalleryTab.Favorites: + return GalleryViewerComponent.FavoritesTitle; + case GalleryTab.Published: + return GalleryViewerComponent.PublishedTitle; + default: + throw new Error(`Unknown tab ${tab}`); + } +} diff --git a/src/Utils/JunoUtils.ts b/src/Utils/JunoUtils.ts index 289412930..d3c6d3823 100644 --- a/src/Utils/JunoUtils.ts +++ b/src/Utils/JunoUtils.ts @@ -1,57 +1,8 @@ -import * as DataModels from "../Contracts/DataModels"; -import { config } from "../Config"; import { RepoListItem } from "../Explorer/Controls/GitHub/GitHubReposComponent"; -import { IPinnedRepo } from "../Juno/JunoClient"; import { IGitHubRepo } from "../GitHub/GitHubClient"; +import { IPinnedRepo } from "../Juno/JunoClient"; export class JunoUtils { - public static async getLikedNotebooks(authorizationToken: string): Promise { - //TODO: Add Get method once juno has it implemented - return { - likedNotebooksContent: [], - userMetadata: { - likedNotebooks: [] - } - }; - } - - public static async getOfficialSampleNotebooks( - authorizationToken: string - ): Promise { - try { - const response = await window.fetch(config.JUNO_ENDPOINT + "/api/notebooks/galleries", { - method: "GET", - headers: { - authorization: authorizationToken - } - }); - if (!response.ok) { - throw new Error("Status code:" + response.status); - } - return await response.json(); - } catch (e) { - throw new Error("Official samples fetch failed."); - } - } - - public static async updateUserMetadata( - authorizationToken: string, - userMetadata: DataModels.UserMetadata - ): Promise { - return undefined; - //TODO: add userMetadata updation code - // TODO: Make sure to throw error if failed - } - - public static async updateNotebookMetadata( - authorizationToken: string, - notebookMetadata: DataModels.NotebookMetadata - ): Promise { - return undefined; - //TODO: add notebookMetadata updation code - // TODO: Make sure to throw error if failed - } - public static toPinnedRepo(item: RepoListItem): IPinnedRepo { return { owner: item.repo.owner, diff --git a/src/Utils/UserUtils.ts b/src/Utils/UserUtils.ts new file mode 100644 index 000000000..168c6b34c --- /dev/null +++ b/src/Utils/UserUtils.ts @@ -0,0 +1,17 @@ +import AuthHeadersUtil from "../Platform/Hosted/Authorization"; +import { decryptJWTToken } from "./AuthorizationUtils"; +import { CosmosClient } from "../Common/CosmosClient"; + +export function getFullName(): string { + let fullName: string; + const user = AuthHeadersUtil.getCachedUser(); + if (user) { + fullName = user.profile.name; + } else { + const authToken = CosmosClient.authorizationToken(); + const props = decryptJWTToken(authToken); + fullName = props.name; + } + + return fullName; +} diff --git a/src/explorer.html b/src/explorer.html index d1db2114f..9600c617f 100644 --- a/src/explorer.html +++ b/src/explorer.html @@ -450,6 +450,10 @@ + +
+ +