Added support for custom image upload during publish to Gallery (#99)

* Added support for custom image upload

- Dropdown gives an option for URL or image upload
- Preview shows how the card will be displayed in the gallery
- base64 converted image stored in metadata document
- Max limit is 1.5MiB for the image

* fixed lint errors

* addressed PR comments

- Added test

* added snapshot

* fixed failing test
This commit is contained in:
Srinath Narayanan 2020-07-17 15:32:39 -07:00 committed by GitHub
parent ffae9baca2
commit 050da28d6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 367 additions and 39 deletions

View File

@ -1,5 +1,4 @@
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";
@ -7,8 +6,8 @@ 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";
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>;
@ -22,7 +21,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
private content: string;
private description: string;
private tags: string;
private thumbnailUrl: string;
private imageSrc: string;
constructor(private container: ViewModels.Explorer, private junoClient: JunoClient) {
this.parameters = ko.observable(Date.now());
@ -87,7 +86,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.description,
this.tags?.split(","),
this.author,
this.thumbnailUrl,
this.imageSrc,
this.content
);
if (!response.data) {
@ -112,44 +111,37 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
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)
private createFormErrorForLargeImageSelection = (formError: string, formErrorDetail: string, area: string): void => {
this.formError = formError;
this.formErrorDetail = formErrorDetail;
const message = `${this.formError}: ${this.formErrorDetail}`;
Logger.logError(message, area);
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
this.triggerRender();
};
return (
<div className="panelContent">
<Stack className="paneMainContent" tokens={{ childrenGap: 20 }}>
<Text>{descriptionPara1}</Text>
<Text>{descriptionPara2}</Text>
<TextField {...descriptionProps} />
<TextField {...tagsProps} />
<TextField {...thumbnailProps} />
</Stack>
</div>
);
private clearFormError = (): void => {
this.formError = undefined;
this.formErrorDetail = undefined;
this.triggerRender();
};
private createContent = (): JSX.Element => {
const publishNotebookPaneProps: PublishNotebookPaneProps = {
notebookName: this.name,
notebookDescription: "",
notebookTags: "",
notebookAuthor: this.author,
notebookCreatedDate: new Date().toISOString(),
onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection,
clearFormError: this.clearFormError
};
return <PublishNotebookPaneComponent {...publishNotebookPaneProps} />;
};
private reset = (): void => {

View File

@ -0,0 +1,6 @@
.publishNotebookPanelContent {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
}

View File

@ -0,0 +1,23 @@
import { shallow } from "enzyme";
import React from "react";
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
describe("PublishNotebookPaneComponent", () => {
it("renders", () => {
const props: PublishNotebookPaneProps = {
notebookName: "SampleNotebook.ipynb",
notebookDescription: "sample description",
notebookTags: "tag1, tag2",
notebookAuthor: "CosmosDB",
notebookCreatedDate: "2020-07-17T00:00:00Z",
onChangeDescription: undefined,
onChangeTags: undefined,
onChangeImageSrc: undefined,
onError: undefined,
clearFormError: undefined
};
const wrapper = shallow(<PublishNotebookPaneComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,205 @@
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 { FileSystemUtil } from "../Notebook/FileSystemUtil";
import "./PublishNotebookPaneComponent.less";
export interface PublishNotebookPaneProps {
notebookName: string;
notebookDescription: string;
notebookTags: string;
notebookAuthor: string;
notebookCreatedDate: string;
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;
notebookDescription: string;
notebookTags: string;
imageSrc: string;
}
export class PublishNotebookPaneComponent extends React.Component<PublishNotebookPaneProps, PublishNotebookPaneState> {
private static readonly maxImageSizeInMib = 1.5;
private static readonly ImageTypes = ["URL", "Custom Image"];
private descriptionPara1: string;
private descriptionPara2: string;
private descriptionProps: ITextFieldProps;
private tagsProps: ITextFieldProps;
private thumbnailUrlProps: ITextFieldProps;
private thumbnailSelectorProps: IDropdownProps;
private imageToBase64: (file: File, updateImageSrc: (result: string) => void) => void;
constructor(props: PublishNotebookPaneProps) {
super(props);
this.state = {
type: PublishNotebookPaneComponent.ImageTypes[0],
notebookDescription: "",
notebookTags: "",
imageSrc: undefined
};
this.imageToBase64 = (file: File, updateImageSrc: (result: string) => void) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function() {
updateImageSrc(reader.result.toString());
};
const onError = this.props.onError;
reader.onerror = function(error) {
const formError = `Failed to convert ${file.name} to base64 format`;
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/selectImageFile";
onError(formError, formErrorDetail, area);
};
};
this.descriptionPara1 =
"This notebook has your data. Please make sure you delete any sensitive data/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",
onChange: (event, newValue) => {
this.props.onChangeImageSrc(newValue);
this.setState({ imageSrc: newValue });
}
};
this.thumbnailSelectorProps = {
label: "Cover image",
defaultSelectedKey: PublishNotebookPaneComponent.ImageTypes[0],
ariaLabel: "Cover image",
options: PublishNotebookPaneComponent.ImageTypes.map((value: string) => ({ text: value, key: value })),
onChange: (event, options) => {
this.setState({ type: options.text });
}
};
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 });
}
};
}
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.descriptionProps} />
</Stack.Item>
<Stack.Item>
<TextField {...this.tagsProps} />
</Stack.Item>
<Stack.Item>
<Dropdown {...this.thumbnailSelectorProps} />
</Stack.Item>
{this.state.type === PublishNotebookPaneComponent.ImageTypes[0] ? (
<Stack.Item>
<TextField {...this.thumbnailUrlProps} />
</Stack.Item>
) : (
<Stack.Item>
<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 });
});
}}
/>
</Stack.Item>
)}
<Stack.Item>
<Text>Preview</Text>
</Stack.Item>
<Stack.Item>
<GalleryCardComponent
data={{
id: undefined,
name: this.props.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: 0,
favorites: 0,
views: 0
}}
isFavorite={false}
showDownload={true}
showDelete={true}
onClick={undefined}
onTagClick={undefined}
onFavoriteClick={undefined}
onUnfavoriteClick={undefined}
onDownloadClick={undefined}
onDeleteClick={undefined}
/>
</Stack.Item>
</Stack>
</div>
);
}
}

View File

@ -0,0 +1,102 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PublishNotebookPaneComponent renders 1`] = `
<div
className="publishNotebookPanelContent"
>
<Stack
className="panelMainContent"
tokens={
Object {
"childrenGap": 20,
}
}
>
<StackItem>
<Text>
This notebook has your data. Please make sure you delete any sensitive data/output before publishing.
</Text>
</StackItem>
<StackItem>
<Text>
Would you like to publish and share "SampleNotebook" to the gallery?
</Text>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Description"
label="Description"
multiline={true}
onChange={[Function]}
required={true}
rows={3}
/>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Tags"
label="Tags"
onChange={[Function]}
placeholder="Optional tag 1, Optional tag 2"
/>
</StackItem>
<StackItem>
<StyledWithResponsiveMode
ariaLabel="Cover image"
defaultSelectedKey="URL"
label="Cover image"
onChange={[Function]}
options={
Array [
Object {
"key": "URL",
"text": "URL",
},
Object {
"key": "Custom Image",
"text": "Custom Image",
},
]
}
/>
</StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Cover image url"
label="Cover image url"
onChange={[Function]}
/>
</StackItem>
<StackItem>
<Text>
Preview
</Text>
</StackItem>
<StackItem>
<GalleryCardComponent
data={
Object {
"author": "CosmosDB",
"created": "2020-07-17T00:00:00Z",
"description": "",
"downloads": 0,
"favorites": 0,
"gitSha": undefined,
"id": undefined,
"isSample": false,
"name": "SampleNotebook.ipynb",
"tags": Array [
"",
],
"thumbnailUrl": undefined,
"views": 0,
}
}
isFavorite={false}
showDelete={true}
showDownload={true}
/>
</StackItem>
</Stack>
</div>
`;