From 2bbc6a83e848a7eb6525d7787ebd9700894b844c Mon Sep 17 00:00:00 2001 From: tweigel-dev <56434333+tweigel-dev@users.noreply.github.com> Date: Wed, 8 Apr 2020 19:47:47 +0200 Subject: [PATCH] feature preview of image-objects (#9239) --- browser/app/js/mime.js | 106 +++++++---- browser/app/js/objects/ObjectActions.js | 52 +++++- browser/app/js/objects/PreviewObjectModal.js | 65 +++++++ .../objects/__tests__/ObjectActions.test.js | 43 +++++ browser/app/js/objects/actions.js | 164 ++++++++++-------- 5 files changed, 315 insertions(+), 115 deletions(-) create mode 100644 browser/app/js/objects/PreviewObjectModal.js diff --git a/browser/app/js/mime.js b/browser/app/js/mime.js index ccf4bfaa0..b7160be0b 100644 --- a/browser/app/js/mime.js +++ b/browser/app/js/mime.js @@ -14,30 +14,67 @@ * limitations under the License. */ -import mimedb from 'mime-types' +import mimedb from "mime-types" const isFolder = (name, contentType) => { - if (name.endsWith('/')) return true + if (name.endsWith("/")) return true return false } const isPdf = (name, contentType) => { - if (contentType === 'application/pdf') return true + if (contentType === "application/pdf") return true + return false +} +const isImage = (name, contentType) => { + if ( + contentType === "image/jpeg" || + contentType === "image/gif" || + contentType === "image/x-icon" || + contentType === "image/png" || + contentType === "image/svg+xml" || + contentType === "image/tiff" || + contentType === "image/webp" + ) + return true return false } const isZip = (name, contentType) => { - if (!contentType || !contentType.includes('/')) return false - if (contentType.split('/')[1].includes('zip')) return true + if (!contentType || !contentType.includes("/")) return false + if (contentType.split("/")[1].includes("zip")) return true return false } const isCode = (name, contentType) => { - const codeExt = ['c', 'cpp', 'go', 'py', 'java', 'rb', 'js', 'pl', 'fs', - 'php', 'css', 'less', 'scss', 'coffee', 'net', 'html', - 'rs', 'exs', 'scala', 'hs', 'clj', 'el', 'scm', 'lisp', - 'asp', 'aspx'] - const ext = name.split('.').reverse()[0] + const codeExt = [ + "c", + "cpp", + "go", + "py", + "java", + "rb", + "js", + "pl", + "fs", + "php", + "css", + "less", + "scss", + "coffee", + "net", + "html", + "rs", + "exs", + "scala", + "hs", + "clj", + "el", + "scm", + "lisp", + "asp", + "aspx", + ] + const ext = name.split(".").reverse()[0] for (var i in codeExt) { if (ext === codeExt[i]) return true } @@ -45,9 +82,9 @@ const isCode = (name, contentType) => { } const isExcel = (name, contentType) => { - if (!contentType || !contentType.includes('/')) return false - const types = ['excel', 'spreadsheet'] - const subType = contentType.split('/')[1] + if (!contentType || !contentType.includes("/")) return false + const types = ["excel", "spreadsheet"] + const subType = contentType.split("/")[1] for (var i in types) { if (subType.includes(types[i])) return true } @@ -55,9 +92,9 @@ const isExcel = (name, contentType) => { } const isDoc = (name, contentType) => { - if (!contentType || !contentType.includes('/')) return false - const types = ['word', '.document'] - const subType = contentType.split('/')[1] + if (!contentType || !contentType.includes("/")) return false + const types = ["word", ".document"] + const subType = contentType.split("/")[1] for (var i in types) { if (subType.includes(types[i])) return true } @@ -65,9 +102,9 @@ const isDoc = (name, contentType) => { } const isPresentation = (name, contentType) => { - if (!contentType || !contentType.includes('/')) return false - var types = ['powerpoint', 'presentation'] - const subType = contentType.split('/')[1] + if (!contentType || !contentType.includes("/")) return false + var types = ["powerpoint", "presentation"] + const subType = contentType.split("/")[1] for (var i in types) { if (subType.includes(types[i])) return true } @@ -76,31 +113,32 @@ const isPresentation = (name, contentType) => { const typeToIcon = (type) => { return (name, contentType) => { - if (!contentType || !contentType.includes('/')) return false - if (contentType.split('/')[0] === type) return true + if (!contentType || !contentType.includes("/")) return false + if (contentType.split("/")[0] === type) return true return false } } export const getDataType = (name, contentType) => { if (contentType === "") { - contentType = mimedb.lookup(name) || 'application/octet-stream' + contentType = mimedb.lookup(name) || "application/octet-stream" } const check = [ - ['folder', isFolder], - ['code', isCode], - ['audio', typeToIcon('audio')], - ['image', typeToIcon('image')], - ['video', typeToIcon('video')], - ['text', typeToIcon('text')], - ['pdf', isPdf], - ['zip', isZip], - ['excel', isExcel], - ['doc', isDoc], - ['presentation', isPresentation] + ["folder", isFolder], + ["code", isCode], + ["audio", typeToIcon("audio")], + ["image", typeToIcon("image")], + ["video", typeToIcon("video")], + ["text", typeToIcon("text")], + ["pdf", isPdf], + ["image", isImage], + ["zip", isZip], + ["excel", isExcel], + ["doc", isDoc], + ["presentation", isPresentation], ] for (var i in check) { if (check[i][1](name, contentType)) return check[i][0] } - return 'other' + return "other" } diff --git a/browser/app/js/objects/ObjectActions.js b/browser/app/js/objects/ObjectActions.js index 394f84613..a1660a630 100644 --- a/browser/app/js/objects/ObjectActions.js +++ b/browser/app/js/objects/ObjectActions.js @@ -19,18 +19,22 @@ import { connect } from "react-redux" import { Dropdown } from "react-bootstrap" import ShareObjectModal from "./ShareObjectModal" import DeleteObjectConfirmModal from "./DeleteObjectConfirmModal" +import PreviewObjectModal from "./PreviewObjectModal" + import * as objectsActions from "./actions" +import { getDataType } from "../mime.js" import { SHARE_OBJECT_EXPIRY_DAYS, SHARE_OBJECT_EXPIRY_HOURS, - SHARE_OBJECT_EXPIRY_MINUTES + SHARE_OBJECT_EXPIRY_MINUTES, } from "../constants" export class ObjectActions extends React.Component { constructor(props) { super(props) this.state = { - showDeleteConfirmation: false + showDeleteConfirmation: false, + showPreview: false, } } shareObject(e) { @@ -53,7 +57,20 @@ export class ObjectActions extends React.Component { } hideDeleteConfirmModal() { this.setState({ - showDeleteConfirmation: false + showDeleteConfirmation: false, + }) + } + getObjectURL(objectname, callback) { + const { getObjectURL } = this.props + getObjectURL(objectname, callback) + } + showPreviewModal(e) { + e.preventDefault() + this.setState({ showPreview: true }) + } + hidePreviewModal() { + this.setState({ + showPreview: false, }) } render() { @@ -69,6 +86,15 @@ export class ObjectActions extends React.Component { > + {getDataType(object.name, object.contentType) == "image" && ( + + + + )} - {(showShareObjectModal && shareObjectName === object.name) && - } + {showShareObjectModal && shareObjectName === object.name && ( + + )} {this.state.showDeleteConfirmation && ( )} + {this.state.showPreview && ( + + )} ) } @@ -94,15 +128,17 @@ const mapStateToProps = (state, ownProps) => { return { object: ownProps.object, showShareObjectModal: state.objects.shareObject.show, - shareObjectName: state.objects.shareObject.object + shareObjectName: state.objects.shareObject.object, } } -const mapDispatchToProps = dispatch => { +const mapDispatchToProps = (dispatch) => { return { shareObject: (object, days, hours, minutes) => dispatch(objectsActions.shareObject(object, days, hours, minutes)), - deleteObject: object => dispatch(objectsActions.deleteObject(object)) + deleteObject: (object) => dispatch(objectsActions.deleteObject(object)), + getObjectURL: (object, callback) => + dispatch(objectsActions.getObjectURL(object, callback)), } } diff --git a/browser/app/js/objects/PreviewObjectModal.js b/browser/app/js/objects/PreviewObjectModal.js new file mode 100644 index 000000000..12b4e016b --- /dev/null +++ b/browser/app/js/objects/PreviewObjectModal.js @@ -0,0 +1,65 @@ +/* + * MinIO Cloud Storage (C) 2020 MinIO, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react" +import { Modal, ModalHeader, ModalBody } from "react-bootstrap" + +class PreviewObjectModal extends React.Component { + constructor(props) { + super(props) + this.state = { + url: "", + } + props.getObjectURL(props.object.name, (url) => { + this.setState({ + url: url, + }) + }) + } + + render() { + const { hidePreviewModal } = this.props + return ( + + Object Preview + +
+ {this.state.url && ( + Image broken + )} +
+
+
+ { + + } +
+
+ ) + } +} +export default PreviewObjectModal diff --git a/browser/app/js/objects/__tests__/ObjectActions.test.js b/browser/app/js/objects/__tests__/ObjectActions.test.js index 6c90c02c4..7339b1a5d 100644 --- a/browser/app/js/objects/__tests__/ObjectActions.test.js +++ b/browser/app/js/objects/__tests__/ObjectActions.test.js @@ -66,6 +66,49 @@ describe("ObjectActions", () => { expect(deleteObject).toHaveBeenCalledWith("obj1") }) + + + + it("should show PreviewObjectModal when preview action is clicked", () => { + const wrapper = shallow( + + ) + wrapper + .find("a") + .at(1) + .simulate("click", { preventDefault: jest.fn() }) + expect(wrapper.state("showPreview")).toBeTruthy() + expect(wrapper.find("PreviewObjectModal").length).toBe(1) + }) + + it("should hide PreviewObjectModal when cancel button is clicked", () => { + const wrapper = shallow( + + ) + wrapper + .find("a") + .at(1) + .simulate("click", { preventDefault: jest.fn() }) + wrapper.find("PreviewObjectModal").prop("hidePreviewModal")() + wrapper.update() + expect(wrapper.state("showPreview")).toBeFalsy() + expect(wrapper.find("PreviewObjectModal").length).toBe(0) + }) + it("should not show PreviewObjectModal when preview action is clicked if object is not an image", () => { + const wrapper = shallow( + + ) + expect(wrapper + .find("a") + .length).toBe(2) // find only the other 2 + }) + it("should call shareObject with object and expiry", () => { const shareObject = jest.fn() const wrapper = shallow( diff --git a/browser/app/js/objects/actions.js b/browser/app/js/objects/actions.js index c1ea37def..371beac27 100644 --- a/browser/app/js/objects/actions.js +++ b/browser/app/js/objects/actions.js @@ -19,7 +19,7 @@ import history from "../history" import { sortObjectsByName, sortObjectsBySize, - sortObjectsByDate + sortObjectsByDate, } from "../utils" import { getCurrentBucket } from "../buckets/selectors" import { getCurrentPrefix, getCheckedList } from "./selectors" @@ -31,7 +31,7 @@ import { SORT_BY_SIZE, SORT_BY_LAST_MODIFIED, SORT_ORDER_ASC, - SORT_ORDER_DESC + SORT_ORDER_DESC, } from "../constants" export const SET_LIST = "objects/SET_LIST" @@ -48,35 +48,35 @@ export const CHECKED_LIST_REMOVE = "objects/CHECKED_LIST_REMOVE" export const CHECKED_LIST_RESET = "objects/CHECKED_LIST_RESET" export const SET_LIST_LOADING = "objects/SET_LIST_LOADING" -export const setList = objects => ({ +export const setList = (objects) => ({ type: SET_LIST, - objects + objects, }) export const resetList = () => ({ - type: RESET_LIST + type: RESET_LIST, }) -export const setListLoading = listLoading => ({ +export const setListLoading = (listLoading) => ({ type: SET_LIST_LOADING, - listLoading + listLoading, }) export const fetchObjects = () => { - return function(dispatch, getState) { + return function (dispatch, getState) { dispatch(resetList()) const { buckets: { currentBucket }, - objects: { currentPrefix } + objects: { currentPrefix }, } = getState() if (currentBucket) { dispatch(setListLoading(true)) return web .ListObjects({ bucketName: currentBucket, - prefix: currentPrefix + prefix: currentPrefix, }) - .then(res => { + .then((res) => { // we need to check if the bucket name and prefix are the same as // when the request was made before updating the displayed objects if ( @@ -85,10 +85,10 @@ export const fetchObjects = () => { ) { let objects = [] if (res.objects) { - objects = res.objects.map(object => { + objects = res.objects.map((object) => { return { ...object, - name: object.name.replace(currentPrefix, "") + name: object.name.replace(currentPrefix, ""), } }) } @@ -104,13 +104,13 @@ export const fetchObjects = () => { dispatch(setListLoading(false)) } }) - .catch(err => { + .catch((err) => { if (web.LoggedIn()) { dispatch( alertActions.set({ type: "danger", message: err.message, - autoClear: true + autoClear: true, }) ) dispatch(resetList()) @@ -123,8 +123,8 @@ export const fetchObjects = () => { } } -export const sortObjects = sortBy => { - return function(dispatch, getState) { +export const sortObjects = (sortBy) => { + return function (dispatch, getState) { const { objects } = getState() let sortOrder = SORT_ORDER_ASC // Reverse sort order if the list is already sorted on same field @@ -149,18 +149,18 @@ const sortObjectsList = (list, sortBy, sortOrder) => { } } -export const setSortBy = sortBy => ({ +export const setSortBy = (sortBy) => ({ type: SET_SORT_BY, - sortBy + sortBy, }) -export const setSortOrder = sortOrder => ({ +export const setSortOrder = (sortOrder) => ({ type: SET_SORT_ORDER, - sortOrder + sortOrder, }) -export const selectPrefix = prefix => { - return function(dispatch, getState) { +export const selectPrefix = (prefix) => { + return function (dispatch, getState) { dispatch(setCurrentPrefix(prefix)) dispatch(fetchObjects()) dispatch(resetCheckedList()) @@ -169,49 +169,49 @@ export const selectPrefix = prefix => { } } -export const setCurrentPrefix = prefix => { +export const setCurrentPrefix = (prefix) => { return { type: SET_CURRENT_PREFIX, - prefix + prefix, } } -export const setPrefixWritable = prefixWritable => ({ +export const setPrefixWritable = (prefixWritable) => ({ type: SET_PREFIX_WRITABLE, - prefixWritable + prefixWritable, }) -export const deleteObject = object => { - return function(dispatch, getState) { +export const deleteObject = (object) => { + return function (dispatch, getState) { const currentBucket = getCurrentBucket(getState()) const currentPrefix = getCurrentPrefix(getState()) const objectName = `${currentPrefix}${object}` return web .RemoveObject({ bucketName: currentBucket, - objects: [objectName] + objects: [objectName], }) .then(() => { dispatch(removeObject(object)) }) - .catch(e => { + .catch((e) => { dispatch( alertActions.set({ type: "danger", - message: e.message + message: e.message, }) ) }) } } -export const removeObject = object => ({ +export const removeObject = (object) => ({ type: REMOVE, - object + object, }) export const deleteCheckedObjects = () => { - return function(dispatch, getState) { + return function (dispatch, getState) { const checkedObjects = getCheckedList(getState()) for (let i = 0; i < checkedObjects.length; i++) { dispatch(deleteObject(checkedObjects[i])) @@ -221,7 +221,7 @@ export const deleteCheckedObjects = () => { } export const shareObject = (object, days, hours, minutes) => { - return function(dispatch, getState) { + return function (dispatch, getState) { const currentBucket = getCurrentBucket(getState()) const currentPrefix = getCurrentPrefix(getState()) const objectName = `${currentPrefix}${object}` @@ -232,22 +232,22 @@ export const shareObject = (object, days, hours, minutes) => { host: location.host, bucket: currentBucket, object: objectName, - expiry: expiry + expiry: expiry, }) - .then(obj => { + .then((obj) => { dispatch(showShareObject(object, obj.url)) dispatch( alertActions.set({ type: "success", - message: `Object shared. Expires in ${days} days ${hours} hours ${minutes} minutes` + message: `Object shared. Expires in ${days} days ${hours} hours ${minutes} minutes`, }) ) }) - .catch(err => { + .catch((err) => { dispatch( alertActions.set({ type: "danger", - message: err.message + message: err.message, }) ) }) @@ -265,7 +265,7 @@ export const shareObject = (object, days, hours, minutes) => { dispatch( alertActions.set({ type: "success", - message: `Object shared.` + message: `Object shared.`, }) ) } @@ -276,18 +276,44 @@ export const showShareObject = (object, url) => ({ type: SET_SHARE_OBJECT, show: true, object, - url + url, }) export const hideShareObject = (object, url) => ({ type: SET_SHARE_OBJECT, show: false, object: "", - url: "" + url: "", }) - -export const downloadObject = object => { - return function(dispatch, getState) { +export const getObjectURL = (object, callback) => { + return function (dispatch, getState) { + const currentBucket = getCurrentBucket(getState()) + const currentPrefix = getCurrentPrefix(getState()) + const objectName = `${currentPrefix}${object}` + const encObjectName = encodeURI(objectName) + if (web.LoggedIn()) { + return web + .CreateURLToken() + .then((res) => { + const url = `${window.location.origin}${minioBrowserPrefix}/download/${currentBucket}/${encObjectName}?token=${res.token}` + callback(url) + }) + .catch((err) => { + dispatch( + alertActions.set({ + type: "danger", + message: err.message, + }) + ) + }) + } else { + const url = `${window.location.origin}${minioBrowserPrefix}/download/${currentBucket}/${encObjectName}?token=` + callback(url) + } + } +} +export const downloadObject = (object) => { + return function (dispatch, getState) { const currentBucket = getCurrentBucket(getState()) const currentPrefix = getCurrentPrefix(getState()) const objectName = `${currentPrefix}${object}` @@ -295,52 +321,46 @@ export const downloadObject = object => { if (web.LoggedIn()) { return web .CreateURLToken() - .then(res => { - const url = `${ - window.location.origin - }${minioBrowserPrefix}/download/${currentBucket}/${encObjectName}?token=${ - res.token - }` + .then((res) => { + const url = `${window.location.origin}${minioBrowserPrefix}/download/${currentBucket}/${encObjectName}?token=${res.token}` window.location = url }) - .catch(err => { + .catch((err) => { dispatch( alertActions.set({ type: "danger", - message: err.message + message: err.message, }) ) }) } else { - const url = `${ - window.location.origin - }${minioBrowserPrefix}/download/${currentBucket}/${encObjectName}?token=` + const url = `${window.location.origin}${minioBrowserPrefix}/download/${currentBucket}/${encObjectName}?token=` window.location = url } } } -export const checkObject = object => ({ +export const checkObject = (object) => ({ type: CHECKED_LIST_ADD, - object + object, }) -export const uncheckObject = object => ({ +export const uncheckObject = (object) => ({ type: CHECKED_LIST_REMOVE, - object + object, }) export const resetCheckedList = () => ({ - type: CHECKED_LIST_RESET + type: CHECKED_LIST_RESET, }) export const downloadCheckedObjects = () => { - return function(dispatch, getState) { + return function (dispatch, getState) { const state = getState() const req = { bucketName: getCurrentBucket(state), prefix: getCurrentPrefix(state), - objects: getCheckedList(state) + objects: getCheckedList(state), } if (!web.LoggedIn()) { const requestUrl = location.origin + "/minio/zip?token=" @@ -348,17 +368,15 @@ export const downloadCheckedObjects = () => { } else { return web .CreateURLToken() - .then(res => { - const requestUrl = `${ - location.origin - }${minioBrowserPrefix}/zip?token=${res.token}` + .then((res) => { + const requestUrl = `${location.origin}${minioBrowserPrefix}/zip?token=${res.token}` downloadZip(requestUrl, req, dispatch) }) - .catch(err => + .catch((err) => dispatch( alertActions.set({ type: "danger", - message: err.message + message: err.message, }) ) ) @@ -374,11 +392,11 @@ const downloadZip = (url, req, dispatch) => { xhr.open("POST", url, true) xhr.responseType = "blob" - xhr.onload = function(e) { + xhr.onload = function (e) { if (this.status == 200) { dispatch(resetCheckedList()) var blob = new Blob([this.response], { - type: "octet/stream" + type: "octet/stream", }) var blobUrl = window.URL.createObjectURL(blob) var separator = req.prefix.length > 1 ? "-" : ""