Repository: codalab/codalab-worksheets (language: python)

Issue:
Add UI buttons for record block, contents, images



Discussion / hints:
Start with images and contents

Fix patch:
diff --git a/frontend/src/components/worksheets/NewUpload/NewUpload.jsx b/frontend/src/components/worksheets/NewUpload/NewUpload.jsx
index a28eaf62b..62bd2121e 100644
--- a/frontend/src/components/worksheets/NewUpload/NewUpload.jsx
+++ b/frontend/src/components/worksheets/NewUpload/NewUpload.jsx
@@ -128,7 +128,8 @@ class NewUpload extends React.Component<{
         let promises = [...files].map(async file => {
             const createBundleData = getDefaultBundleMetadata(name || file.name, description);
             let url = `/rest/bundles?worksheet=${ worksheetUUID }`;
-            if (after_sort_key) {
+            // after_sort_key can be equal to 0
+            if (after_sort_key || after_sort_key === 0) {
                 url += `&after_sort_key=${ after_sort_key }`;
             }
             async function createFileBundle(url, data) {
@@ -287,6 +288,8 @@ class NewUpload extends React.Component<{
         return (
             <React.Fragment>
                 <input
+                    key={this.props.after_sort_key} // force the NewUpload to update when the focused item changes
+
                     type="file"
                     id="codalab-file-upload-input"
                     multiple={true}
diff --git a/frontend/src/components/worksheets/Worksheet/Worksheet.js b/frontend/src/components/worksheets/Worksheet/Worksheet.js
index e71f34a72..ca9c0080a 100644
--- a/frontend/src/components/worksheets/Worksheet/Worksheet.js
+++ b/frontend/src/components/worksheets/Worksheet/Worksheet.js
@@ -1404,6 +1404,7 @@ class Worksheet extends React.Component {
             textDeleted = false,
             fromDeleteCommand = false,
             uploadFiles = false,
+            addImage = false, // whether the reload is caused by adding an image
         } = {},
     ) => {
         if (partialUpdateItems === undefined) {
@@ -1454,6 +1455,10 @@ class Worksheet extends React.Component {
                             items[focus].mode !== 'table_block'
                         ) {
                             this.setFocus(focus >= 0 ? focus + 1 : 'end', 0);
+                        } else if (this.state.focusIndex === -1) {
+                            // If currently the top of the worksheet is focused and a new bundle has been uploaded to the worksheet,
+                            // the first item on the worksheet should be focused.
+                            this.setFocus(0, 0);
                         } else if (this.state.subFocusIndex !== undefined) {
                             // Focus on the next bundle row
                             this.setFocus(focus >= 0 ? focus : 'end', this.state.subFocusIndex + 1);
@@ -1507,11 +1512,29 @@ class Worksheet extends React.Component {
                             this.setFocus(focus, this.state.subFocusIndex);
                         }
                     }
-                    this.setState({
-                        updating: false,
-                        version: this.state.version + 1,
-                        numOfBundles: numOfBundles,
-                    });
+                    this.setState(
+                        {
+                            updating: false,
+                            version: this.state.version + 1,
+                            numOfBundles: numOfBundles,
+                        },
+                        () => {
+                            if (addImage) {
+                                const subFocusIndex = this.state.subFocusIndex || 0;
+                                let focusIndexPair = this.state.focusIndex + ',' + subFocusIndex;
+                                let index = this.state.ws.info.block_to_raw[focusIndexPair];
+                                if (index === undefined) {
+                                    // the newly uploaded image currently does not create a new separate block (e.g. be added as a bundle row to a table)
+                                    // retry to get the raw index
+                                    focusIndexPair =
+                                        this.state.focusIndex - 1 + ',' + (subFocusIndex + 1);
+                                    index = this.state.ws.info.block_to_raw[focusIndexPair];
+                                }
+                                // index is the raw index of the new uploaded image
+                                this.addImageDisplay(index);
+                            }
+                        },
+                    );
                     this.checkRunBundle(this.state.ws.info);
                 }.bind(this),
                 error: function(xhr, status, err) {
@@ -1626,6 +1649,34 @@ class Worksheet extends React.Component {
         this.setState({ uploadAnchor: e.currentTarget });
     };
 
+    /**
+     * @param index index of new image's source line
+     */
+    addImageDisplay = (index: number) => {
+        // add %display line to the worksheet source right before the newly uploaded image
+        this.setState(
+            (prevState) => {
+                const items = ['% display image / width=250'];
+                return {
+                    ws: {
+                        ...prevState.ws,
+                        info: {
+                            ...prevState.ws.info,
+                            source: [
+                                ...prevState.ws.info.source.slice(0, index),
+                                ...items,
+                                ...prevState.ws.info.source.slice(index),
+                            ],
+                        },
+                    },
+                };
+            },
+            () => {
+                // since one line has been added before the newly uploaded image, now the rawIndex should be added by 1
+                this.saveAndUpdateWorksheet(false, index + 1);
+            },
+        );
+    };
     render() {
         const { classes } = this.props;
         const { anchorEl, uploadAnchor } = this.state;
@@ -1788,6 +1839,7 @@ class Worksheet extends React.Component {
                 updateBundleBlockSchema={this.updateBundleBlockSchema}
                 updateSchemaItem={this.updateSchemaItem}
                 setDeleteSchemaItemCallback={this.setDeleteSchemaItemCallback}
+                addImageDisplay={this.addImageDisplay}
             />
         );
 
diff --git a/frontend/src/components/worksheets/WorksheetItemList.js b/frontend/src/components/worksheets/WorksheetItemList.js
index 25a2572d1..32bbd5bcf 100644
--- a/frontend/src/components/worksheets/WorksheetItemList.js
+++ b/frontend/src/components/worksheets/WorksheetItemList.js
@@ -14,6 +14,7 @@ import WorksheetItem from './items/WorksheetItem';
 import ItemWrapper from './items/ItemWrapper';
 import PlaceholderItem from './items/PlaceholderItem';
 import NewUpload from './NewUpload/NewUpload';
+import ImageEditor from './items/ImageEditor';
 import TextEditorItem from './items/TextEditorItem';
 import NewRun from './NewRun';
 import { withStyles } from '@material-ui/core/styles';
@@ -53,10 +54,7 @@ const addWorksheetItems = function(props, worksheet_items, prevItem, afterItem)
     props.key = props.id = 'codalab-worksheet-item-' + props.focusIndex;
     props.url = url;
     props.prevItem = prevItem;
-    props.after_sort_key = getAfterSortKey(
-        props.item,
-        props.item.mode === 'markup_block' ? undefined : props.subFocusIndex,
-    );
+    props.after_sort_key = getAfterSortKey(item, props.subFocusIndex);
     props.ids = getIds(item);
     // showNewButtonsAfterEachBundleRow is set to true when we have a bundle table, because in this case,
     // we must show the new upload / new run buttons after each row in the table (in the BundleRow component)
@@ -200,7 +198,7 @@ class WorksheetItemList extends React.Component {
                     isDummyItem: true,
                     text: '',
                     mode: 'markup_block',
-                    sort_keys: [-1],
+                    sort_keys: [-1], // the dummy item represents the top of the worksheet, so its sort key is -1
                     ids: [null],
                     is_refined: true,
                 },
@@ -240,6 +238,7 @@ class WorksheetItemList extends React.Component {
                         onHideNewText: this.props.onHideNewText,
                         onHideNewRerun: this.props.onHideNewRerun,
                         onHideNewSchema: this.props.onHideNewSchema,
+                        onHideNewImage: this.props.onHideNewImage,
                         handleCheckBundle: this.props.handleCheckBundle,
                         confirmBundleRowAction: this.props.confirmBundleRowAction,
                         setDeleteItemCallback: this.props.setDeleteItemCallback,
@@ -304,7 +303,7 @@ class WorksheetItemList extends React.Component {
                                 ],
                                 header: ['field', 'generalized-path', 'post-processor'],
                                 schema_name: '',
-                                sort_keys: [-1],
+                                sort_keys: [0],
                             }}
                             create={true}
                             updateSchemaItem={this.props.updateSchemaItem}
@@ -325,6 +324,16 @@ class WorksheetItemList extends React.Component {
                         // the file hasn't changed)
                         onUploadFinish={(e) => this.setState({ newUploadKey: Math.random() + '' })}
                     />
+                    <ImageEditor
+                        key={this.state.newUploadKey + 1}
+                        after_sort_key={getAfterSortKey(focusedItem, this.props.subFocusIndex)}
+                        worksheetUUID={info.uuid}
+                        reloadWorksheet={this.props.reloadWorksheet}
+                        onUploadFinish={(e) => this.setState({ newUploadKey: Math.random() + '' })}
+                        ws={this.props.ws}
+                        focusIndex={this.props.focusIndex}
+                        subFocusIndex={this.props.subFocusIndex}
+                    />
                 </>
             );
         } else {
diff --git a/frontend/src/components/worksheets/items/ActionButtons.js b/frontend/src/components/worksheets/items/ActionButtons.js
index 8f8c92b5a..1b0cd910b 100644
--- a/frontend/src/components/worksheets/items/ActionButtons.js
+++ b/frontend/src/components/worksheets/items/ActionButtons.js
@@ -11,6 +11,7 @@ import Menu from '@material-ui/core/Menu';
 import MenuItem from '@material-ui/core/MenuItem';
 import Tooltip from '@material-ui/core/Tooltip';
 import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd';
+import AddPhotoAlternateIcon from '@material-ui/icons/AddPhotoAlternate';
 
 const StyledMenuItem = withStyles((theme) => ({
     root: {
@@ -32,6 +33,10 @@ class ActionButtons extends React.Component<{
         this.setState({ anchorEl: null });
     };
 
+    uploadNewImage = (e) => {
+        document.querySelector('label[for=codalab-image-upload-input]').click();
+    };
+
     render() {
         const {
             classes,
@@ -182,6 +187,20 @@ class ActionButtons extends React.Component<{
                         Schema
                     </Button>
                 </Tooltip>
+                <Tooltip title='Add an image'>
+                    <Button
+                        size='small'
+                        color='inherit'
+                        aria-label='image'
+                        onClick={this.uploadNewImage}
+                        disabled={!editPermission}
+                        id='add-image-button'
+                    >
+                        <label htmlFor='codalab-image-upload-input'></label>
+                        <AddPhotoAlternateIcon className={classes.buttonIcon} />
+                        Image
+                    </Button>
+                </Tooltip>
             </div>
         );
     }
diff --git a/frontend/src/components/worksheets/items/ImageEditor.js b/frontend/src/components/worksheets/items/ImageEditor.js
new file mode 100644
index 000000000..b1ef0ef03
--- /dev/null
+++ b/frontend/src/components/worksheets/items/ImageEditor.js
@@ -0,0 +1,153 @@
+import React from 'react';
+import 'rc-slider/assets/index.css';
+import { withStyles } from '@material-ui/core/styles';
+import { getQueryParams } from '../NewUpload/NewUpload.jsx';
+import $ from 'jquery';
+import { createAlertText, getDefaultBundleMetadata } from '../../../util/worksheet_utils';
+import { FILE_SIZE_LIMIT_B, FILE_SIZE_LIMIT_GB } from '../../../constants';
+
+const styles = (theme) => ({});
+
+class ImageEditor extends React.Component<{
+    /** JSS styling object. */
+    classes: {},
+
+    /** The worksheet to insert into **/
+    worksheetUUID: string,
+
+    /** Insert after this bundle **/
+    after_sort_key: string,
+}> {
+    state = {
+        /* Whether the upload is in progress */
+        uploading: false,
+    };
+
+    inputFile = React.createRef();
+
+    setFile = () => {
+        const files = this.inputFile.current.files;
+
+        if (!files.length) {
+            return;
+        }
+        this.uploadFiles(files);
+    };
+
+    readFileAsync(bundleUuid, file) {
+        return new Promise((resolve, reject) => {
+            let reader = new FileReader();
+            reader.onload = () => {
+                let arrayBuffer = reader.result,
+                    bytesArray = new Uint8Array(arrayBuffer);
+                let url =
+                    '/rest/bundles/' + bundleUuid + '/contents/blob/?' + getQueryParams(file.name);
+                $.ajax({
+                    url: url,
+                    type: 'PUT',
+                    contentType: 'application/octet-stream',
+                    data: new Blob([bytesArray]),
+                    processData: false,
+                    success: function(data) {
+                        resolve(data);
+                    },
+                    error: function(error) {
+                        this.clearProgress();
+                        alert(createAlertText(error.responseText, 'refresh and try again.'));
+                        this.props.onUploadFinish();
+                        reject(error);
+                    }.bind(this),
+                });
+            };
+            reader.readAsArrayBuffer(file);
+        });
+    }
+
+    asyncUploadFiles = async (files) => {
+        const { worksheetUUID, after_sort_key } = this.props;
+        const { name, description } = this.state;
+        this.setState({
+            uploading: true,
+        });
+
+        let promises = [...files].map(async (file) => {
+            const createBundleData = getDefaultBundleMetadata(name || file.name, description);
+            let url = `/rest/bundles?worksheet=${worksheetUUID}`;
+            if (after_sort_key || after_sort_key === 0) {
+                url += `&after_sort_key=${after_sort_key}`;
+            }
+
+            async function createFileBundle(url, data) {
+                let result;
+                try {
+                    result = await $.ajax({
+                        url: url,
+                        data: data,
+                        contentType: 'application/json',
+                        type: 'POST',
+                    });
+                    return result;
+                } catch (error) {
+                    this.clearProgress();
+                    alert(createAlertText(url, error.responseText));
+                }
+            }
+
+            const bundle = await createFileBundle(url, JSON.stringify(createBundleData));
+            const bundleUuid = bundle.data[0].id;
+            const promise = await this.readFileAsync(bundleUuid, file);
+            return promise;
+        });
+        const moveIndex = true;
+        const addImage = true;
+        const param = { moveIndex, addImage };
+        await Promise.all(promises);
+        this.clearProgress();
+        this.props.reloadWorksheet(undefined, undefined, param);
+        this.props.onUploadFinish();
+    };
+
+    uploadFiles = (files) => {
+        if (!files) {
+            return;
+        }
+
+        let fileSize = 0;
+        for (const file of files) {
+            fileSize += file.size;
+        }
+
+        if (fileSize > FILE_SIZE_LIMIT_B) {
+            alert(
+                'Image size is large than ' +
+                    FILE_SIZE_LIMIT_GB +
+                    'GB. Please upload your image(s) through CLI.',
+            );
+            return;
+        }
+        this.asyncUploadFiles(files);
+    };
+
+    clearProgress = () => {
+        this.setState({ uploading: false });
+    };
+
+    render() {
+        return (
+            <React.Fragment>
+                <input
+                    type='file'
+                    id='codalab-image-upload-input'
+                    multiple={false} // only support uploading one image per time
+                    style={{ display: 'none' }}
+                    ref={this.inputFile}
+                    onChange={this.setFile}
+                    accept='image/*'
+                    key={this.props.after_sort_key} // force to update the imageEditor component when after_sort_key changes
+                />
+            </React.Fragment>
+        );
+    }
+}
+
+export default withStyles(styles)(ImageEditor);
diff --git a/frontend/src/components/worksheets/items/ItemWrapper.js b/frontend/src/components/worksheets/items/ItemWrapper.js
index ecdb75238..fe2222630 100644
--- a/frontend/src/components/worksheets/items/ItemWrapper.js
+++ b/frontend/src/components/worksheets/items/ItemWrapper.js
@@ -64,6 +64,7 @@ class ItemWrapper extends React.Component {
                 {showNewSchema && (
                     <SchemaItem
                         ws={this.props.ws}
+                        after_sort_key={after_sort_key}
                         onSubmit={() => this.props.onHideNewSchema()}
                         reloadWorksheet={reloadWorksheet}
                         editPermission={true}
diff --git a/frontend/src/components/worksheets/items/TableItem/BundleRow.js b/frontend/src/components/worksheets/items/TableItem/BundleRow.js
index 532f81aed..edfc2c0de 100644
--- a/frontend/src/components/worksheets/items/TableItem/BundleRow.js
+++ b/frontend/src/components/worksheets/items/TableItem/BundleRow.js
@@ -323,6 +323,7 @@ class BundleRow extends Component {
                 )}
                 {/** ---------------------------------------------------------------------------------------------------
                  *  Rerun
+                 *  Insert the new run/text/schema below the bundle row, so add 1 to after_sort_key
                  */}
                 {showNewRun === 1 && (
                     <TableRow>
@@ -334,7 +335,7 @@ class BundleRow extends Component {
                                         this.setState({ showNewRun: 0, showDetail: false });
                                         onHideNewRerun();
                                     }}
-                                    after_sort_key={this.props.after_sort_key}
+                                    after_sort_key={this.props.after_sort_key + 1}
                                     reloadWorksheet={reloadWorksheet}
                                     defaultRun={runProp}
                                 />
@@ -347,7 +348,7 @@ class BundleRow extends Component {
                         <TableCell colSpan='100%' classes={{ root: classes.insertPanel }}>
                             <div className={classes.insertBox}>
                                 <NewRun
-                                    after_sort_key={this.props.after_sort_key}
+                                    after_sort_key={this.props.after_sort_key + 1}
                                     ws={this.props.ws}
                                     onSubmit={() => this.props.onHideNewRun()}
                                     reloadWorksheet={reloadWorksheet}
@@ -363,7 +364,7 @@ class BundleRow extends Component {
                                 <TextEditorItem
                                     ids={this.props.ids}
                                     mode='create'
-                                    after_sort_key={this.props.after_sort_key}
+                                    after_sort_key={this.props.after_sort_key + 1}
                                     worksheetUUID={this.props.worksheetUUID}
                                     reloadWorksheet={reloadWorksheet}
                                     closeEditor={() => {
@@ -379,7 +380,7 @@ class BundleRow extends Component {
                         <TableCell colSpan='100%' classes={{ root: classes.insertPanel }}>
                             <div className={classes.insertBox}>
                                 <SchemaItem
-                                    after_sort_key={this.props.after_sort_key}
+                                    after_sort_key={this.props.after_sort_key + 1}
                                     ws={this.props.ws}
                                     onSubmit={() => this.props.onHideNewSchema()}
                                     reloadWorksheet={reloadWorksheet}
@@ -395,7 +396,7 @@ class BundleRow extends Component {
                                         ],
                                         header: ['field', 'generalized-path', 'post-processor'],
                                         schema_name: '',
-                                        sort_keys: [-1],
+                                        sort_keys: [this.props.after_sort_key + 2],
                                     }}
                                     create={true}
                                     updateSchemaItem={this.props.updateSchemaItem}
diff --git a/frontend/src/util/worksheet_utils.js b/frontend/src/util/worksheet_utils.js
index c5545ab1e..b22c18c9a 100644
--- a/frontend/src/util/worksheet_utils.js
+++ b/frontend/src/util/worksheet_utils.js
@@ -301,7 +301,13 @@ export function createHandleRedirectFn(worksheetUuid) {
 // Return the sort key at index subFocusIndex, if subFocusIndex is defined.
 // Otherwise, return the largest sort_key.
 export function getAfterSortKey(item, subFocusIndex) {
-    if (!item) return 0;
+    // The default after_sort_key is -1 when inserting an item on top of the worksheet,
+    // so the item's sort_key should always be >= 0 (sort_key > after_sort_key)
+    if (!item) return -1;
+    if (item.mode === 'image_block') {
+        // image_block stores the sort_key in a different property than do other blocks
+        return item['bundles_spec']['bundle_infos'][0]['sort_key'];
+    }
     const sort_keys = item.sort_keys || [];
     if (sort_keys[subFocusIndex] || sort_keys[subFocusIndex] === 0) {
         return sort_keys[subFocusIndex];


Test patch:
diff --git a/frontend/src/__tests__/__snapshots__/Worksheet.test.js.snap b/frontend/src/__tests__/__snapshots__/Worksheet.test.js.snap
index 1c73bb848..f926320a0 100644
--- a/frontend/src/__tests__/__snapshots__/Worksheet.test.js.snap
+++ b/frontend/src/__tests__/__snapshots__/Worksheet.test.js.snap
@@ -263,6 +263,41 @@ Object {
                           class="MuiTouchRipple-root-238"
                         />
                       </button>
+                      <button
+                        aria-label="image"
+                        class="MuiButtonBase-root-141 MuiButton-root-115 MuiButton-text-117 MuiButton-flat-120 MuiButton-sizeSmall-138 MuiButton-colorInherit-136"
+                        id="add-image-button"
+                        tabindex="0"
+                        title="Add an image"
+                        type="button"
+                      >
+                        <span
+                          class="MuiButton-label-116"
+                        >
+                          <label
+                            for="codalab-image-upload-input"
+                          />
+                          <svg
+                            aria-hidden="true"
+                            class="MuiSvgIcon-root-144 ActionButtons-buttonIcon-112"
+                            focusable="false"
+                            role="presentation"
+                            viewBox="0 0 24 24"
+                          >
+                            <path
+                              d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"
+                            />
+                            <path
+                              d="M0 0h24v24H0z"
+                              fill="none"
+                            />
+                          </svg>
+                          Image
+                        </span>
+                        <span
+                          class="MuiTouchRipple-root-238"
+                        />
+                      </button>
                     </div>
                   </div>
                   <div
@@ -687,6 +722,12 @@ Object {
                       type="file"
                       webkitdirectory="true"
                     />
+                    <input
+                      accept="image/*"
+                      id="codalab-image-upload-input"
+                      style="display: none;"
+                      type="file"
+                    />
                   </div>
                 </div>
               </div>
@@ -1057,6 +1098,41 @@ Object {
                         class="MuiTouchRipple-root-238"
                       />
                     </button>
+                    <button
+                      aria-label="image"
+                      class="MuiButtonBase-root-141 MuiButton-root-115 MuiButton-text-117 MuiButton-flat-120 MuiButton-sizeSmall-138 MuiButton-colorInherit-136"
+                      id="add-image-button"
+                      tabindex="0"
+                      title="Add an image"
+                      type="button"
+                    >
+                      <span
+                        class="MuiButton-label-116"
+                      >
+                        <label
+                          for="codalab-image-upload-input"
+                        />
+                        <svg
+                          aria-hidden="true"
+                          class="MuiSvgIcon-root-144 ActionButtons-buttonIcon-112"
+                          focusable="false"
+                          role="presentation"
+                          viewBox="0 0 24 24"
+                        >
+                          <path
+                            d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"
+                          />
+                          <path
+                            d="M0 0h24v24H0z"
+                            fill="none"
+                          />
+                        </svg>
+                        Image
+                      </span>
+                      <span
+                        class="MuiTouchRipple-root-238"
+                      />
+                    </button>
                   </div>
                 </div>
                 <div
@@ -1481,6 +1557,12 @@ Object {
                     type="file"
                     webkitdirectory="true"
                   />
+                  <input
+                    accept="image/*"
+                    id="codalab-image-upload-input"
+                    style="display: none;"
+                    type="file"
+                  />
                 </div>
               </div>
             </div>