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
This commit is contained in:
Tanuj Mittal
2020-06-30 11:47:21 -07:00
committed by GitHub
parent dd199e6565
commit 7512b3c1d5
41 changed files with 2801 additions and 1193 deletions

View File

@@ -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(<NotebookMetadataComponent {...props} />);
@@ -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(<NotebookMetadataComponent {...props} />);

View File

@@ -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<void>;
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<string>((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<NotebookMetadataComponentProps> {
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 (
<div className="notebookViewerMetadataContainer">
<h3 className="title">{this.props.notebookName}</h3>
<Stack tokens={{ childrenGap: 10 }}>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 30 }}>
<Text variant="xxLarge" nowrap>
{FileSystemUtil.stripExtension(this.props.data.name, "ipynb")}
</Text>
<Text>
<IconButton
iconProps={{ iconName: this.props.isFavorite ? "HeartFill" : "Heart" }}
onClick={this.props.isFavorite ? this.props.onUnfavoriteClick : this.props.onFavoriteClick}
/>
{this.props.data.favorites} likes
</Text>
<PrimaryButton text={this.props.downloadButtonText} onClick={this.props.onDownloadClick} />
</Stack>
{this.props.notebookMetadata && (
<div className="decoration">
{this.props.container ? (
<IconButton
iconProps={{ iconName: this.state.liked ? "HeartFill" : "Heart" }}
styles={iconButtonStyles}
onClick={this.onLike}
/>
) : (
<Icon iconName="Heart" styles={iconStyles} />
)}
<Text variant="large" styles={mainHelpfulTextStyles}>
{this.state.notebookMetadata.likes} likes
</Text>
</div>
)}
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 10 }}>
<Persona text={this.props.data.author} size={PersonaSize.size32} />
<Text>{dateString}</Text>
<Text>
<Icon iconName="RedEye" /> {this.props.data.views}
</Text>
<Text>
<Icon iconName="Download" />
{this.props.data.downloads}
</Text>
</Stack>
{this.props.container && (
<button aria-label="downloadButton" className="downloadButton" onClick={this.onDownload}>
Download Notebook
</button>
)}
<Text nowrap>
{this.props.data.tags?.map((tag, index, array) => (
<span key={tag}>
<Link onClick={(): void => this.props.onTagClick(tag)}>{tag}</Link>
{index === array.length - 1 ? <></> : ", "}
</span>
))}
</Text>
{this.props.notebookMetadata && (
<>
<div>
<Persona
className="persona"
text={this.props.notebookMetadata.author}
secondaryText={this.props.notebookMetadata.date}
/>
</div>
<div>
<div className="extras">
<Icon iconName="RedEye" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.state.notebookMetadata.views}
</Text>
<Icon iconName="Download" styles={subtleIconStyles} />
<Text variant="small" styles={subtleHelpfulTextStyles}>
{this.state.notebookMetadata.downloads}
</Text>
</div>
<Text variant="small" styles={siteTextStyles}>
{this.props.notebookMetadata.tags.join(", ")}
</Text>
</div>
<div>
<Text variant="small" styles={helpfulTextStyles}>
<b>Description:</b>
<p>{this.props.notebookMetadata.description}</p>
</Text>
</div>
</>
)}
</div>
<Text variant="large" styles={{ root: { fontWeight: FontWeights.semibold } }}>
Description
</Text>
<Text>{this.props.data.description}</Text>
</Stack>
);
}
}

View File

@@ -1,7 +1,7 @@
@import "../../../../less/Common/Constants";
.notebookViewerContainer {
padding: @DefaultSpace;
padding: 30px;
height: 100%;
width: 100%;
overflow-y: auto;

View File

@@ -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<void>;
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<NotebookViewerComponentProps, NotebookViewerComponentState>
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<any> {
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<void> {
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 (
<div className="notebookViewerContainer">
<NotebookMetadataComponent
notebookMetadata={this.props.notebookMetadata}
notebookName={this.props.notebookName}
container={this.props.container}
notebookContent={this.state.content}
onNotebookMetadataChange={this.props.onNotebookMetadataChange}
isLikedNotebook={this.props.isLikedNotebook}
/>
{this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, {
hideInputs: this.props.hideInputs
})}
{this.props.backNavigationText ? (
<Link onClick={this.props.onBackClick}>
<Icon iconName="Back" /> {this.props.backNavigationText}
</Link>
) : (
<></>
)}
{this.state.galleryItem ? (
<div style={{ margin: 10 }}>
<NotebookMetadataComponent
data={this.state.galleryItem}
isFavorite={this.state.isFavorite}
downloadButtonText={
this.props.container ? "Download to my notebooks" : "Edit/Run in Cosmos DB data explorer"
}
onTagClick={this.props.onTagClick}
onFavoriteClick={this.favoriteItem}
onUnfavoriteClick={this.unfavoriteItem}
onDownloadClick={this.downloadItem}
/>
</div>
) : (
<></>
)}
{this.notebookComponentBootstrapper.renderComponent(NotebookReadOnlyRenderer, { hideInputs: true })}
{this.state.dialogProps && <DialogComponent {...this.state.dialogProps} />}
</div>
);
}
public static getDerivedStateFromProps(
props: NotebookViewerComponentProps,
state: NotebookViewerComponentState
): Partial<NotebookViewerComponentState> {
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<void> => {
GalleryUtils.favoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, item =>
this.setState({ galleryItem: item, isFavorite: true })
);
};
private unfavoriteItem = async (): Promise<void> => {
GalleryUtils.unfavoriteItem(this.props.container, this.props.junoClient, this.state.galleryItem, item =>
this.setState({ galleryItem: item, isFavorite: false })
);
};
private downloadItem = async (): Promise<void> => {
GalleryUtils.downloadItem(this, this.props.container, this.props.junoClient, this.state.galleryItem, item =>
this.setState({ galleryItem: item })
);
};
}

View File

@@ -1,25 +1,199 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NotebookMetadataComponent renders liked notebook 1`] = `
<div
className="notebookViewerMetadataContainer"
<Stack
tokens={
Object {
"childrenGap": 10,
}
}
>
<h3
className="title"
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 30,
}
}
verticalAlign="center"
>
My notebook
</h3>
</div>
<Text
nowrap={true}
variant="xxLarge"
>
name
</Text>
<Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "HeartFill",
}
}
/>
0
likes
</Text>
<CustomizedPrimaryButton
text="Download"
/>
</Stack>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
verticalAlign="center"
>
<StyledPersonaBase
size={11}
text="author"
/>
<Text>
Invalid Date
</Text>
<Text>
<StyledIconBase
iconName="RedEye"
/>
0
</Text>
<Text>
<StyledIconBase
iconName="Download"
/>
0
</Text>
</Stack>
<Text
nowrap={true}
>
<span
key="tag"
>
<StyledLinkBase
onClick={[Function]}
>
tag
</StyledLinkBase>
</span>
</Text>
<Text
styles={
Object {
"root": Object {
"fontWeight": 600,
},
}
}
variant="large"
>
Description
</Text>
<Text>
description
</Text>
</Stack>
`;
exports[`NotebookMetadataComponent renders un-liked notebook 1`] = `
<div
className="notebookViewerMetadataContainer"
<Stack
tokens={
Object {
"childrenGap": 10,
}
}
>
<h3
className="title"
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 30,
}
}
verticalAlign="center"
>
My notebook
</h3>
</div>
<Text
nowrap={true}
variant="xxLarge"
>
name
</Text>
<Text>
<CustomizedIconButton
iconProps={
Object {
"iconName": "Heart",
}
}
/>
0
likes
</Text>
<CustomizedPrimaryButton
text="Download"
/>
</Stack>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
verticalAlign="center"
>
<StyledPersonaBase
size={11}
text="author"
/>
<Text>
Invalid Date
</Text>
<Text>
<StyledIconBase
iconName="RedEye"
/>
0
</Text>
<Text>
<StyledIconBase
iconName="Download"
/>
0
</Text>
</Stack>
<Text
nowrap={true}
>
<span
key="tag"
>
<StyledLinkBase
onClick={[Function]}
>
tag
</StyledLinkBase>
</span>
</Text>
<Text
styles={
Object {
"root": Object {
"fontWeight": 600,
},
}
}
variant="large"
>
Description
</Text>
<Text>
description
</Text>
</Stack>
`;