mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-21 09:51:11 +00:00
Migrate Publish Notebook Pane to React (#641)
Co-authored-by: Steve Faulkner <southpolesteve@gmail.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
205
src/Explorer/Panes/PublishNotebookPane/PublishNotebookPane.tsx
Normal file
205
src/Explorer/Panes/PublishNotebookPane/PublishNotebookPane.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user