Migrate Publish Notebook Pane to React (#641)

Co-authored-by: Steve Faulkner <southpolesteve@gmail.com>
This commit is contained in:
Hardikkumar Nai
2021-04-28 06:10:03 +05:30
committed by GitHub
parent 154db1dcd5
commit 8f3cb7282b
17 changed files with 802 additions and 885 deletions

View File

@@ -8,14 +8,15 @@ describe("PublishNotebookPaneComponent", () => {
notebookName: "SampleNotebook.ipynb",
notebookDescription: "sample description",
notebookTags: "tag1, tag2",
imageSrc: "https://i.ytimg.com/vi/E_lByLdKeKY/maxresdefault.jpg",
notebookAuthor: "CosmosDB",
notebookCreatedDate: "2020-07-17T00:00:00Z",
notebookObject: undefined,
notebookParentDomElement: undefined,
onChangeName: undefined,
onChangeDescription: undefined,
onChangeTags: undefined,
onChangeImageSrc: undefined,
setNotebookName: undefined,
setNotebookDescription: undefined,
setNotebookTags: undefined,
setImageSrc: undefined,
onError: undefined,
clearFormError: undefined,
};

View File

@@ -0,0 +1,205 @@
import { ImmutableNotebook, toJS } from "@nteract/commutable";
import React, { FunctionComponent, useEffect, useState } from "react";
import { HttpStatusCodes } from "../../../Common/Constants";
import { getErrorMessage, getErrorStack, handleError } from "../../../Common/ErrorHandlingUtils";
import { JunoClient } from "../../../Juno/JunoClient";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import { CodeOfConductComponent } from "../../Controls/NotebookGallery/CodeOfConductComponent";
import { GalleryTab } from "../../Controls/NotebookGallery/GalleryViewerComponent";
import Explorer from "../../Explorer";
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
import {
GenericRightPaneComponent,
GenericRightPaneProps,
} from "../GenericRightPaneComponent/GenericRightPaneComponent";
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
export interface PublishNotebookPaneAProps {
explorer: Explorer;
closePanel: () => void;
openNotificationConsole: () => void;
junoClient: JunoClient;
name: string;
author: string;
notebookContent: string | ImmutableNotebook;
parentDomElement: HTMLElement;
}
export const PublishNotebookPane: FunctionComponent<PublishNotebookPaneAProps> = ({
explorer: container,
junoClient,
closePanel,
name,
author,
notebookContent,
parentDomElement,
}: PublishNotebookPaneAProps): JSX.Element => {
const [isCodeOfConductAccepted, setIsCodeOfConductAccepted] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
const [formError, setFormError] = useState<string>("");
const [formErrorDetail, setFormErrorDetail] = useState<string>("");
const [isExecuting, setIsExecuting] = useState<boolean>();
const [notebookName, setNotebookName] = useState<string>(name);
const [notebookDescription, setNotebookDescription] = useState<string>("");
const [notebookTags, setNotebookTags] = useState<string>("");
const [imageSrc, setImageSrc] = useState<string>();
const CodeOfConductAccepted = async () => {
try {
const response = await junoClient.isCodeOfConductAccepted();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
setIsCodeOfConductAccepted(response.data);
} catch (error) {
handleError(
error,
"PublishNotebookPaneAdapter/isCodeOfConductAccepted",
"Failed to check if code of conduct was accepted"
);
}
};
const [notebookObject, setNotebookObject] = useState<ImmutableNotebook>();
useEffect(() => {
CodeOfConductAccepted();
let newContent;
if (typeof notebookContent === "string") {
newContent = notebookContent as string;
} else {
newContent = JSON.stringify(toJS(notebookContent));
setNotebookObject(notebookContent);
}
setContent(newContent);
}, []);
const submit = async (): Promise<void> => {
const clearPublishingMessage = NotificationConsoleUtils.logConsoleProgress(`Publishing ${name} to gallery`);
setIsExecuting(true);
let startKey: number;
if (!notebookName || !notebookDescription || !author || !imageSrc) {
setFormError(`Failed to publish ${notebookName} to gallery`);
setFormErrorDetail("Name, description, author and cover image are required");
createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit");
setIsExecuting(false);
return;
}
try {
startKey = traceStart(Action.NotebooksGalleryPublish, {});
const response = await junoClient.publishNotebook(
notebookName,
notebookDescription,
notebookTags?.split(","),
author,
imageSrc,
content
);
const data = response.data;
if (data) {
let isPublishPending = false;
if (data.pendingScanJobIds?.length > 0) {
isPublishPending = true;
NotificationConsoleUtils.logConsoleInfo(
`Content of ${name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).`
);
} else {
NotificationConsoleUtils.logConsoleInfo(`Published ${notebookName} to gallery`);
container.openGallery(GalleryTab.Published);
}
traceSuccess(
Action.NotebooksGalleryPublish,
{
notebookId: data.id,
isPublishPending,
},
startKey
);
}
} catch (error) {
traceFailure(
Action.NotebooksGalleryPublish,
{
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
const errorMessage = getErrorMessage(error);
setFormError(`Failed to publish ${FileSystemUtil.stripExtension(notebookName, "ipynb")} to gallery`);
setFormErrorDetail(`${errorMessage}`);
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", formError);
return;
} finally {
clearPublishingMessage();
setIsExecuting(false);
}
closePanel();
};
const createFormError = (formError: string, formErrorDetail: string, area: string): void => {
setFormError(formError);
setFormErrorDetail(formErrorDetail);
handleError(formErrorDetail, area, formError);
};
const clearFormError = (): void => {
setFormError("");
setFormErrorDetail("");
};
const props: GenericRightPaneProps = {
container: container,
formError: formError,
formErrorDetail: formErrorDetail,
id: "publishnotebookpane",
isExecuting: isExecuting,
title: "Publish to gallery",
submitButtonText: "Publish",
onSubmit: () => submit(),
onClose: closePanel,
isSubmitButtonHidden: !isCodeOfConductAccepted,
};
const publishNotebookPaneProps: PublishNotebookPaneProps = {
notebookDescription,
notebookTags,
imageSrc,
notebookName,
notebookAuthor: author,
notebookCreatedDate: new Date().toISOString(),
notebookObject: notebookObject,
notebookParentDomElement: parentDomElement,
onError: createFormError,
clearFormError: clearFormError,
setNotebookName,
setNotebookDescription,
setNotebookTags,
setImageSrc,
};
return (
<GenericRightPaneComponent {...props}>
{!isCodeOfConductAccepted ? (
<div style={{ padding: "25px", marginTop: "10px" }}>
<CodeOfConductComponent
junoClient={junoClient}
onAcceptCodeOfConduct={(isAccepted) => {
setIsCodeOfConductAccepted(isAccepted);
}}
/>
</div>
) : (
<PublishNotebookPaneComponent {...publishNotebookPaneProps} />
)}
</GenericRightPaneComponent>
);
};

View File

@@ -0,0 +1,297 @@
import { ImmutableNotebook } from "@nteract/commutable";
import Html2Canvas from "html2canvas";
import { Dropdown, IDropdownProps, ITextFieldProps, Stack, Text, TextField } from "office-ui-fabric-react";
import React, { FunctionComponent, useState } from "react";
import { GalleryCardComponent } from "../../Controls/NotebookGallery/Cards/GalleryCardComponent";
import * as FileSystemUtil from "../../Notebook/FileSystemUtil";
import { NotebookUtil } from "../../Notebook/NotebookUtil";
import "./styled.less";
export interface PublishNotebookPaneProps {
notebookName: string;
notebookAuthor: string;
notebookTags: string;
imageSrc: string;
notebookDescription: string;
notebookCreatedDate: string;
notebookObject: ImmutableNotebook;
notebookParentDomElement?: HTMLElement;
onError: (formError: string, formErrorDetail: string, area: string) => void;
clearFormError: () => void;
setNotebookName: (newValue: string) => void;
setNotebookDescription: (newValue: string) => void;
setNotebookTags: (newValue: string) => void;
setImageSrc: (newValue: string) => void;
}
enum ImageTypes {
Url = "URL",
CustomImage = "Custom Image",
TakeScreenshot = "Take Screenshot",
UseFirstDisplayOutput = "Use First Display Output",
}
export const PublishNotebookPaneComponent: FunctionComponent<PublishNotebookPaneProps> = ({
notebookName,
notebookTags,
imageSrc,
notebookDescription,
notebookAuthor,
notebookCreatedDate,
notebookObject,
notebookParentDomElement,
onError,
clearFormError,
setNotebookName,
setNotebookDescription,
setNotebookTags,
setImageSrc,
}: PublishNotebookPaneProps) => {
const [type, setType] = useState<string>(ImageTypes.CustomImage);
const CARD_WIDTH = 256;
const cardImageHeight = 144;
const cardHeightToWidthRatio = cardImageHeight / CARD_WIDTH;
const maxImageSizeInMib = 1.5;
const descriptionPara1 =
"When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.";
const descriptionPara2 = `Would you like to publish and share "${FileSystemUtil.stripExtension(
notebookName,
"ipynb"
)}" to the gallery?`;
const options: ImageTypes[] = [ImageTypes.CustomImage, ImageTypes.Url];
const thumbnailSelectorProps: IDropdownProps = {
label: "Cover image",
defaultSelectedKey: ImageTypes.CustomImage,
ariaLabel: "Cover image",
options: options.map((value: string) => ({ text: value, key: value })),
onChange: async (event, options) => {
setImageSrc("");
clearFormError();
if (options.text === ImageTypes.TakeScreenshot) {
try {
await takeScreenshot(notebookParentDomElement, screenshotErrorHandler);
} catch (error) {
screenshotErrorHandler(error);
}
} else if (options.text === ImageTypes.UseFirstDisplayOutput) {
try {
await takeScreenshot(findFirstOutput(), firstOutputErrorHandler);
} catch (error) {
firstOutputErrorHandler(error);
}
}
setType(options.text);
},
};
const thumbnailUrlProps: ITextFieldProps = {
label: "Cover image url",
ariaLabel: "Cover image url",
required: true,
onChange: (event, newValue) => {
setImageSrc(newValue);
},
};
const screenshotErrorHandler = (error: Error) => {
const formError = "Failed to take screen shot";
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/takeScreenshot";
onError(formError, formErrorDetail, area);
};
const firstOutputErrorHandler = (error: Error) => {
const formError = "Failed to capture first output";
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/UseFirstOutput";
onError(formError, formErrorDetail, area);
};
if (notebookParentDomElement) {
options.push(ImageTypes.TakeScreenshot);
if (notebookObject) {
options.push(ImageTypes.UseFirstDisplayOutput);
}
}
const imageToBase64 = (file: File, updateImageSrc: (result: string) => void) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
updateImageSrc(reader.result.toString());
};
reader.onerror = (error) => {
const formError = `Failed to convert ${file.name} to base64 format`;
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/selectImageFile";
onError(formError, formErrorDetail, area);
};
};
const takeScreenshot = (target: HTMLElement, onError: (error: Error) => void): void => {
const updateImageSrcWithScreenshot = (canvasUrl: string): void => {
setImageSrc(canvasUrl);
};
target.scrollIntoView();
Html2Canvas(target, {
useCORS: true,
allowTaint: true,
scale: 1,
logging: true,
})
.then((canvas) => {
//redraw canvas to fit Card Cover Image dimensions
const originalImageData = canvas.toDataURL();
const requiredHeight = parseInt(canvas.style.width.split("px")[0]) * cardHeightToWidthRatio;
canvas.height = requiredHeight;
const context = canvas.getContext("2d");
const image = new Image();
image.src = originalImageData;
image.onload = () => {
context.drawImage(image, 0, 0);
updateImageSrcWithScreenshot(canvas.toDataURL());
};
})
.catch((error) => {
onError(error);
});
};
const renderThumbnailSelectors = (type: string) => {
switch (type) {
case ImageTypes.Url:
return <TextField {...thumbnailUrlProps} />;
case ImageTypes.CustomImage:
return (
<input
id="selectImageFile"
type="file"
accept="image/*"
onChange={(event) => {
const file = event.target.files[0];
if (file.size / 1024 ** 2 > maxImageSizeInMib) {
event.target.value = "";
const formError = `Failed to upload ${file.name}`;
const formErrorDetail = `Image is larger than ${maxImageSizeInMib} MiB. Please Choose a different image.`;
const area = "PublishNotebookPaneComponent/selectImageFile";
onError(formError, formErrorDetail, area);
setImageSrc("");
return;
} else {
clearFormError();
}
imageToBase64(file, (result: string) => {
setImageSrc(result);
});
}}
/>
);
default:
return <></>;
}
};
const findFirstOutput = (): HTMLElement => {
const indexOfFirstCodeCellWithDisplay = NotebookUtil.findFirstCodeCellWithDisplay(notebookObject);
const cellOutputDomElements = notebookParentDomElement.querySelectorAll<HTMLElement>(".nteract-cell-outputs");
return cellOutputDomElements[indexOfFirstCodeCellWithDisplay];
};
return (
<div className="publishNotebookPanelContent">
<Stack className="panelMainContent" tokens={{ childrenGap: 20 }}>
<Stack.Item>
<Text>{descriptionPara1}</Text>
</Stack.Item>
<Stack.Item>
<Text>{descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<TextField
label="Name"
ariaLabel="Name"
defaultValue={FileSystemUtil.stripExtension(notebookName, "ipynb")}
required
onChange={(event, newValue) => {
const notebookName = newValue + ".ipynb";
setNotebookName(notebookName);
}}
/>
</Stack.Item>
<Stack.Item>
<TextField
label="Description"
ariaLabel="Description"
multiline
rows={3}
required
onChange={(event, newValue) => {
setNotebookDescription(newValue);
}}
/>
</Stack.Item>
<Stack.Item>
<TextField
label="Tags"
ariaLabel="Tags"
placeholder="Optional tag 1, Optional tag 2"
onChange={(event, newValue) => {
setNotebookTags(newValue);
}}
/>
</Stack.Item>
<Stack.Item>
<Dropdown {...thumbnailSelectorProps} />
</Stack.Item>
<Stack.Item>{renderThumbnailSelectors(type)}</Stack.Item>
<Stack.Item>
<Text>Preview</Text>
</Stack.Item>
<Stack.Item>
<GalleryCardComponent
data={{
id: undefined,
name: notebookName,
description: notebookDescription,
gitSha: undefined,
tags: notebookTags.split(","),
author: notebookAuthor,
thumbnailUrl: imageSrc,
created: notebookCreatedDate,
isSample: false,
downloads: undefined,
favorites: undefined,
views: undefined,
newCellId: undefined,
policyViolations: undefined,
pendingScanJobIds: undefined,
}}
isFavorite={undefined}
showDownload={false}
showDelete={false}
onClick={() => undefined}
onTagClick={undefined}
onFavoriteClick={undefined}
onUnfavoriteClick={undefined}
onDownloadClick={undefined}
onDeleteClick={undefined}
/>
</Stack.Item>
</Stack>
</div>
);
};

View File

@@ -88,7 +88,7 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
Object {
"author": "CosmosDB",
"created": "2020-07-17T00:00:00Z",
"description": "",
"description": "sample description",
"downloads": undefined,
"favorites": undefined,
"gitSha": undefined,
@@ -99,12 +99,14 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
"pendingScanJobIds": undefined,
"policyViolations": undefined,
"tags": Array [
"",
"tag1",
" tag2",
],
"thumbnailUrl": undefined,
"thumbnailUrl": "https://i.ytimg.com/vi/E_lByLdKeKY/maxresdefault.jpg",
"views": undefined,
}
}
onClick={[Function]}
showDelete={false}
showDownload={false}
/>

View File

@@ -1,244 +0,0 @@
import { toJS } from "@nteract/commutable";
import { ImmutableNotebook } from "@nteract/commutable/src";
import ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { HttpStatusCodes } from "../../Common/Constants";
import { getErrorMessage, getErrorStack, handleError } from "../../Common/ErrorHandlingUtils";
import { JunoClient } from "../../Juno/JunoClient";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
import { GalleryTab } from "../Controls/NotebookGallery/GalleryViewerComponent";
import Explorer from "../Explorer";
import * as FileSystemUtil from "../Notebook/FileSystemUtil";
import {
GenericRightPaneComponent,
GenericRightPaneProps,
} from "./GenericRightPaneComponent/GenericRightPaneComponent";
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>;
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 imageSrc: string;
private notebookObject: ImmutableNotebook;
private parentDomElement: HTMLElement;
private isCodeOfConductAccepted: boolean;
constructor(private container: 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,
formError: this.formError,
formErrorDetail: this.formErrorDetail,
id: "publishnotebookpane",
isExecuting: this.isExecuting,
title: "Publish to gallery",
submitButtonText: "Publish",
onClose: () => this.close(),
onSubmit: () => this.submit(),
isSubmitButtonHidden: !this.isCodeOfConductAccepted,
};
const publishNotebookPaneProps: PublishNotebookPaneProps = {
notebookName: this.name,
notebookDescription: "",
notebookTags: "",
notebookAuthor: this.author,
notebookCreatedDate: new Date().toISOString(),
notebookObject: this.notebookObject,
notebookParentDomElement: this.parentDomElement,
onChangeName: (newValue: string) => (this.name = newValue),
onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormError,
clearFormError: this.clearFormError,
};
return (
<GenericRightPaneComponent {...props}>
{!this.isCodeOfConductAccepted ? (
<div style={{ padding: "15px", marginTop: "10px" }}>
<CodeOfConductComponent
junoClient={this.junoClient}
onAcceptCodeOfConduct={() => {
this.isCodeOfConductAccepted = true;
this.triggerRender();
}}
/>
</div>
) : (
<PublishNotebookPaneComponent {...publishNotebookPaneProps} />
)}
</GenericRightPaneComponent>
);
}
public triggerRender(): void {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
public async open(
name: string,
author: string,
notebookContent: string | ImmutableNotebook,
parentDomElement: HTMLElement
): Promise<void> {
try {
const response = await this.junoClient.isCodeOfConductAccepted();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
this.isCodeOfConductAccepted = response.data;
} catch (error) {
handleError(
error,
"PublishNotebookPaneAdapter/isCodeOfConductAccepted",
"Failed to check if code of conduct was accepted"
);
}
this.name = name;
this.author = author;
if (typeof notebookContent === "string") {
this.content = notebookContent as string;
} else {
this.content = JSON.stringify(toJS(notebookContent));
this.notebookObject = notebookContent;
}
this.parentDomElement = parentDomElement;
this.isOpened = true;
this.triggerRender();
}
public close(): void {
this.reset();
this.triggerRender();
}
public async submit(): Promise<void> {
const clearPublishingMessage = NotificationConsoleUtils.logConsoleProgress(`Publishing ${this.name} to gallery`);
this.isExecuting = true;
this.triggerRender();
let startKey: number;
if (!this.name || !this.description || !this.author || !this.imageSrc) {
const formError = `Failed to publish ${this.name} to gallery`;
const formErrorDetail = "Name, description, author and cover image are required";
this.createFormError(formError, formErrorDetail, "PublishNotebookPaneAdapter/submit");
this.isExecuting = false;
return;
}
try {
startKey = traceStart(Action.NotebooksGalleryPublish, {});
const response = await this.junoClient.publishNotebook(
this.name,
this.description,
this.tags?.split(","),
this.author,
this.imageSrc,
this.content
);
const data = response.data;
if (data) {
let isPublishPending = false;
if (data.pendingScanJobIds?.length > 0) {
isPublishPending = true;
NotificationConsoleUtils.logConsoleInfo(
`Content of ${this.name} is currently being scanned for illegal content. It will not be available in the public gallery until the review is complete (may take a few days).`
);
} else {
NotificationConsoleUtils.logConsoleInfo(`Published ${this.name} to gallery`);
this.container.openGallery(GalleryTab.Published);
}
traceSuccess(
Action.NotebooksGalleryPublish,
{
notebookId: data.id,
isPublishPending,
},
startKey
);
}
} catch (error) {
traceFailure(
Action.NotebooksGalleryPublish,
{
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
const errorMessage = getErrorMessage(error);
this.formError = `Failed to publish ${FileSystemUtil.stripExtension(this.name, "ipynb")} to gallery`;
this.formErrorDetail = `${errorMessage}`;
handleError(errorMessage, "PublishNotebookPaneAdapter/submit", this.formError);
return;
} finally {
clearPublishingMessage();
this.isExecuting = false;
this.triggerRender();
}
this.close();
}
private createFormError = (formError: string, formErrorDetail: string, area: string): void => {
this.formError = formError;
this.formErrorDetail = formErrorDetail;
handleError(formErrorDetail, area, formError);
this.triggerRender();
};
private clearFormError = (): void => {
this.formError = undefined;
this.formErrorDetail = undefined;
this.triggerRender();
};
private reset = (): void => {
this.isOpened = false;
this.isExecuting = false;
this.formError = undefined;
this.formErrorDetail = undefined;
this.name = undefined;
this.author = undefined;
this.content = undefined;
this.description = undefined;
this.tags = undefined;
this.imageSrc = undefined;
this.notebookObject = undefined;
this.parentDomElement = undefined;
this.isCodeOfConductAccepted = undefined;
};
}

View File

@@ -1,326 +0,0 @@
import { ITextFieldProps, Stack, Text, TextField, Dropdown, IDropdownProps } from "office-ui-fabric-react";
import * as React from "react";
import { GalleryCardComponent } from "../Controls/NotebookGallery/Cards/GalleryCardComponent";
import * as FileSystemUtil from "../Notebook/FileSystemUtil";
import "./PublishNotebookPaneComponent.less";
import Html2Canvas from "html2canvas";
import { ImmutableNotebook } from "@nteract/commutable/src";
import { NotebookUtil } from "../Notebook/NotebookUtil";
export interface PublishNotebookPaneProps {
notebookName: string;
notebookDescription: string;
notebookTags: string;
notebookAuthor: string;
notebookCreatedDate: string;
notebookObject: ImmutableNotebook;
notebookParentDomElement?: HTMLElement;
onChangeName: (newValue: string) => void;
onChangeDescription: (newValue: string) => void;
onChangeTags: (newValue: string) => void;
onChangeImageSrc: (newValue: string) => void;
onError: (formError: string, formErrorDetail: string, area: string) => void;
clearFormError: () => void;
}
interface PublishNotebookPaneState {
type: string;
notebookName: string;
notebookDescription: string;
notebookTags: string;
imageSrc: string;
}
enum ImageTypes {
Url = "URL",
CustomImage = "Custom Image",
TakeScreenshot = "Take Screenshot",
UseFirstDisplayOutput = "Use First Display Output",
}
export class PublishNotebookPaneComponent extends React.Component<PublishNotebookPaneProps, PublishNotebookPaneState> {
private static readonly maxImageSizeInMib = 1.5;
private descriptionPara1: string;
private descriptionPara2: string;
private nameProps: ITextFieldProps;
private descriptionProps: ITextFieldProps;
private tagsProps: ITextFieldProps;
private thumbnailUrlProps: ITextFieldProps;
private thumbnailSelectorProps: IDropdownProps;
private imageToBase64: (file: File, updateImageSrc: (result: string) => void) => void;
private takeScreenshot: (target: HTMLElement, onError: (error: Error) => void) => void;
constructor(props: PublishNotebookPaneProps) {
super(props);
this.state = {
type: ImageTypes.CustomImage,
notebookName: props.notebookName,
notebookDescription: "",
notebookTags: "",
imageSrc: undefined,
};
this.imageToBase64 = (file: File, updateImageSrc: (result: string) => void) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
updateImageSrc(reader.result.toString());
};
const onError = this.props.onError;
reader.onerror = (error) => {
const formError = `Failed to convert ${file.name} to base64 format`;
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/selectImageFile";
onError(formError, formErrorDetail, area);
};
};
this.takeScreenshot = (target: HTMLElement, onError: (error: Error) => void): void => {
const updateImageSrcWithScreenshot = (canvasUrl: string): void => {
this.props.onChangeImageSrc(canvasUrl);
this.setState({ imageSrc: canvasUrl });
};
target.scrollIntoView();
Html2Canvas(target, {
useCORS: true,
allowTaint: true,
scale: 1,
logging: true,
})
.then((canvas) => {
//redraw canvas to fit Card Cover Image dimensions
const originalImageData = canvas.toDataURL();
const requiredHeight =
parseInt(canvas.style.width.split("px")[0]) * GalleryCardComponent.cardHeightToWidthRatio;
canvas.height = requiredHeight;
const context = canvas.getContext("2d");
const image = new Image();
image.src = originalImageData;
image.onload = () => {
context.drawImage(image, 0, 0);
updateImageSrcWithScreenshot(canvas.toDataURL());
};
})
.catch((error) => {
onError(error);
});
};
this.descriptionPara1 =
"When published, this notebook will appear in the Azure Cosmos DB notebooks public gallery. Make sure you have removed any sensitive data or output before publishing.";
this.descriptionPara2 = `Would you like to publish and share "${FileSystemUtil.stripExtension(
this.props.notebookName,
"ipynb"
)}" to the gallery?`;
this.thumbnailUrlProps = {
label: "Cover image url",
ariaLabel: "Cover image url",
required: true,
onChange: (event, newValue) => {
this.props.onChangeImageSrc(newValue);
this.setState({ imageSrc: newValue });
},
};
const screenshotErrorHandler = (error: Error) => {
const formError = "Failed to take screen shot";
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/takeScreenshot";
this.props.onError(formError, formErrorDetail, area);
};
const firstOutputErrorHandler = (error: Error) => {
const formError = "Failed to capture first output";
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/UseFirstOutput";
this.props.onError(formError, formErrorDetail, area);
};
const options: ImageTypes[] = [ImageTypes.CustomImage, ImageTypes.Url];
if (this.props.notebookParentDomElement) {
options.push(ImageTypes.TakeScreenshot);
if (this.props.notebookObject) {
options.push(ImageTypes.UseFirstDisplayOutput);
}
}
this.thumbnailSelectorProps = {
label: "Cover image",
defaultSelectedKey: ImageTypes.CustomImage,
ariaLabel: "Cover image",
options: options.map((value: string) => ({ text: value, key: value })),
onChange: async (event, options) => {
this.setState({ imageSrc: undefined });
this.props.onChangeImageSrc(undefined);
this.props.clearFormError();
if (options.text === ImageTypes.TakeScreenshot) {
try {
await this.takeScreenshot(this.props.notebookParentDomElement, screenshotErrorHandler);
} catch (error) {
screenshotErrorHandler(error);
}
} else if (options.text === ImageTypes.UseFirstDisplayOutput) {
try {
await this.takeScreenshot(this.findFirstOutput(), firstOutputErrorHandler);
} catch (error) {
firstOutputErrorHandler(error);
}
}
this.setState({ type: options.text });
},
};
this.nameProps = {
label: "Name",
ariaLabel: "Name",
defaultValue: FileSystemUtil.stripExtension(this.props.notebookName, "ipynb"),
required: true,
onChange: (event, newValue) => {
const notebookName = newValue + ".ipynb";
this.props.onChangeName(notebookName);
this.setState({ notebookName });
},
};
this.descriptionProps = {
label: "Description",
ariaLabel: "Description",
multiline: true,
rows: 3,
required: true,
onChange: (event, newValue) => {
this.props.onChangeDescription(newValue);
this.setState({ notebookDescription: newValue });
},
};
this.tagsProps = {
label: "Tags",
ariaLabel: "Tags",
placeholder: "Optional tag 1, Optional tag 2",
onChange: (event, newValue) => {
this.props.onChangeTags(newValue);
this.setState({ notebookTags: newValue });
},
};
}
private renderThumbnailSelectors(type: string) {
switch (type) {
case ImageTypes.Url:
return <TextField {...this.thumbnailUrlProps} />;
case ImageTypes.CustomImage:
return (
<input
id="selectImageFile"
type="file"
accept="image/*"
onChange={(event) => {
const file = event.target.files[0];
if (file.size / 1024 ** 2 > PublishNotebookPaneComponent.maxImageSizeInMib) {
event.target.value = "";
const formError = `Failed to upload ${file.name}`;
const formErrorDetail = `Image is larger than ${PublishNotebookPaneComponent.maxImageSizeInMib} MiB. Please Choose a different image.`;
const area = "PublishNotebookPaneComponent/selectImageFile";
this.props.onError(formError, formErrorDetail, area);
this.props.onChangeImageSrc(undefined);
this.setState({ imageSrc: undefined });
return;
} else {
this.props.clearFormError();
}
this.imageToBase64(file, (result: string) => {
this.props.onChangeImageSrc(result);
this.setState({ imageSrc: result });
});
}}
/>
);
default:
return <></>;
}
}
private findFirstOutput(): HTMLElement {
const indexOfFirstCodeCellWithDisplay = NotebookUtil.findFirstCodeCellWithDisplay(this.props.notebookObject);
const cellOutputDomElements = this.props.notebookParentDomElement.querySelectorAll<HTMLElement>(
".nteract-cell-outputs"
);
return cellOutputDomElements[indexOfFirstCodeCellWithDisplay];
}
public render(): JSX.Element {
return (
<div className="publishNotebookPanelContent">
<Stack className="panelMainContent" tokens={{ childrenGap: 20 }}>
<Stack.Item>
<Text>{this.descriptionPara1}</Text>
</Stack.Item>
<Stack.Item>
<Text>{this.descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<TextField {...this.nameProps} />
</Stack.Item>
<Stack.Item>
<TextField {...this.descriptionProps} />
</Stack.Item>
<Stack.Item>
<TextField {...this.tagsProps} />
</Stack.Item>
<Stack.Item>
<Dropdown {...this.thumbnailSelectorProps} />
</Stack.Item>
<Stack.Item>{this.renderThumbnailSelectors(this.state.type)}</Stack.Item>
<Stack.Item>
<Text>Preview</Text>
</Stack.Item>
<Stack.Item>
<GalleryCardComponent
data={{
id: undefined,
name: this.state.notebookName,
description: this.state.notebookDescription,
gitSha: undefined,
tags: this.state.notebookTags.split(","),
author: this.props.notebookAuthor,
thumbnailUrl: this.state.imageSrc,
created: this.props.notebookCreatedDate,
isSample: false,
downloads: undefined,
favorites: undefined,
views: undefined,
newCellId: undefined,
policyViolations: undefined,
pendingScanJobIds: undefined,
}}
isFavorite={undefined}
showDownload={false}
showDelete={false}
onClick={undefined}
onTagClick={undefined}
onFavoriteClick={undefined}
onUnfavoriteClick={undefined}
onDownloadClick={undefined}
onDeleteClick={undefined}
/>
</Stack.Item>
</Stack>
</div>
);
}
}