diff --git a/client/src/app/App.js b/client/src/app/App.js index 6e9e9eb477..dcff924913 100644 --- a/client/src/app/App.js +++ b/client/src/app/App.js @@ -1,11 +1,6 @@ import React, { PureComponent } from 'react'; -import { WithCache } from './cached'; - -import { - Fill, - SlotFillRoot -} from './slot-fill'; +import debug from 'debug'; import { assign, @@ -14,11 +9,19 @@ import { reduce } from 'min-dash'; +import { WithCache } from './cached'; + +import { DropZone } from './drop-zone'; + +import { + Fill, + SlotFillRoot +} from './slot-fill'; + import Toolbar from './Toolbar'; import Log from './Log'; -import debug from 'debug'; import { ModalConductor } from './modals'; @@ -426,8 +429,7 @@ export class App extends PureComponent { } = this.state; const { - dialog, - fileSystem + dialog } = globals; const providers = tabsProvider.getProviders(); @@ -443,17 +445,7 @@ export class App extends PureComponent { return; } - const files = await Promise.all(filePaths.map(async (filePath) => { - const fileType = getFileTypeFromExtension(filePath); - - const provider = tabsProvider.getProvider(fileType); - - const encoding = provider.encoding ? provider.encoding : ENCODING_UTF8; - - return await fileSystem.readFile(filePath, { - encoding - }); - })); + const files = await this.readFileList(filePaths); await this.openFiles(files); } @@ -593,6 +585,45 @@ export class App extends PureComponent { return openedTabs.reverse(); } + readFileList = async filePaths => { + const readOperations = filePaths.map(this.readFileFromPath); + + const rawFiles = await Promise.all(readOperations); + + const files = rawFiles.filter(Boolean); + + return files; + } + + readFileFromPath = async (filePath) => { + const { + globals, + tabsProvider + } = this.props; + + const fileType = getFileTypeFromExtension(filePath); + + const provider = tabsProvider.getProvider(fileType); + + const encoding = provider.encoding ? provider.encoding : ENCODING_UTF8; + + let file = null; + + try { + file = await globals.fileSystem.readFile(filePath, { + encoding + }); + } catch (error) { + if (error.code === 'EISDIR') { + return this.handleError(new Error(`Cannot open directory: ${filePath}`)); + } + + this.handleError(error); + } + + return file; + } + findOpenTab(file) { const { @@ -1309,6 +1340,18 @@ export class App extends PureComponent { this.triggerAction('close-tab', { tabId: tab.id }).catch(console.error); } + handleDrop = async (files) => { + const filePaths = Array.from(files).map(({ path }) => path); + + try { + const files = await this.readFileList(filePaths); + + await this.openFiles(files); + } catch (error) { + this.handleError(error); + } + } + loadConfig = (key, ...args) => { return this.props.globals.config.get(key, this.state.activeTab, ...args); } @@ -1367,145 +1410,152 @@ export class App extends PureComponent { const canSave = this.isUnsaved(activeTab) || this.isDirty(activeTab); return ( -
+ + +
+ + + + + + + + + - + + - + + + + - - - - - - - - - - - - - - - - - - - { - tabState.exportAs && + + - } -
- + { + tabState.exportAs && + + + } - - { - - } - -
- - + + + + { + + } + +
+ + + + + - - - -
+ + + + ); } } @@ -1534,6 +1584,8 @@ function missingProvider(providerType) { class LoadingTab extends PureComponent { + triggerAction() {} + render() { return ( diff --git a/client/src/app/__tests__/AppSpec.js b/client/src/app/__tests__/AppSpec.js index d6b8ac977c..1f5aa92813 100644 --- a/client/src/app/__tests__/AppSpec.js +++ b/client/src/app/__tests__/AppSpec.js @@ -2134,6 +2134,52 @@ describe('', function() { }); + + describe('#handleDrop', function() { + + it('should try to open each dropped file', async function() { + + // given + const directoryReadError = new Error(); + directoryReadError.code = 'EISDIR'; + + const files = [ + { + path: '/dev/null/' + }, + { + path: './CamundaModeler' + }, + { + path: './diagram.bpmn' + } + ]; + + const fileSystem = new FileSystem(); + + const readFileStub = sinon.stub(fileSystem, 'readFile') + .onFirstCall().rejects(directoryReadError) + .onSecondCall().rejects(directoryReadError) + .onThirdCall().resolves({ contents: '' }); + + const { + app + } = createApp({ + globals: { + fileSystem + } + }); + + // when + await app.handleDrop(files); + + // then + expect(readFileStub).to.be.calledThrice; + + }); + + }); + }); diff --git a/client/src/app/__tests__/mocks/index.js b/client/src/app/__tests__/mocks/index.js index 9c457e3ffd..f2176b33aa 100644 --- a/client/src/app/__tests__/mocks/index.js +++ b/client/src/app/__tests__/mocks/index.js @@ -88,6 +88,16 @@ class FakeTab extends Component { } + +const noopProvider = { + getComponent() { + return null; + }, + getInitialContents() { + return null; + } +}; + export class TabsProvider { constructor(resolveTab) { @@ -187,7 +197,7 @@ export class TabsProvider { } getProvider(type) { - return this.providers[type]; + return this.providers[type] || noopProvider; } getTabComponent(type) { diff --git a/client/src/app/drop-zone/DropZone.js b/client/src/app/drop-zone/DropZone.js new file mode 100644 index 0000000000..a7a1a57c15 --- /dev/null +++ b/client/src/app/drop-zone/DropZone.js @@ -0,0 +1,91 @@ +import React from 'react'; + +import css from './DropZone.less'; + + +export class DropZone extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + draggingOver: false + }; + } + + handleDragOver = event => { + if (!this.isDragAllowed(event)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + + if (this.state.draggingOver) { + return; + } + + event.stopPropagation(); + + this.setState({ draggingOver: true }); + } + + /** + * @param {DragEvent} event + */ + isDragAllowed(event) { + const { dataTransfer } = event; + + return Array.from(dataTransfer.items) + .some(({ kind, type }) => type === '' && kind === 'file'); + } + + handleDragLeave = event => { + event.preventDefault(); + event.stopPropagation(); + + if (this.state.draggingOver && !event.relatedTarget) { + this.setState({ draggingOver: false }); + } + } + + handleDrop = async (event) => { + if (!this.state.draggingOver) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + this.setState({ draggingOver: false }); + + this.props.onDrop(event.dataTransfer.files); + } + + render() { + return ( +
+ { this.state.draggingOver ? : null } + { this.props.children } +
+ ); + } +} + +DropZone.defaultProps = { + onDrop: () => {} +}; + +function DropOverlay() { + return ( +
+
+
Drop diagrams here
+
+
+ ); +} diff --git a/client/src/app/drop-zone/DropZone.less b/client/src/app/drop-zone/DropZone.less new file mode 100644 index 0000000000..744eca9ad4 --- /dev/null +++ b/client/src/app/drop-zone/DropZone.less @@ -0,0 +1,36 @@ +:local(.DropZone) { + height: 100%; + width: 100%; +} + +:local(.DropOverlay) { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + + padding: 120px 50px; + + background: rgba(255, 255, 255, 0.8); + + pointer-events: none; + + z-index: 1000; + + .box { + text-align: center; + border: dashed 4px #DDD; + height: 100%; + vertical-align: middle; + + > div { + font-size: 26px; + color: #999; + margin: auto; + + top: 20%; + position: relative; + } + } +} \ No newline at end of file diff --git a/client/src/app/drop-zone/__tests__/DropZoneSpec.js b/client/src/app/drop-zone/__tests__/DropZoneSpec.js new file mode 100644 index 0000000000..778102ec75 --- /dev/null +++ b/client/src/app/drop-zone/__tests__/DropZoneSpec.js @@ -0,0 +1,227 @@ +/* global sinon */ + +import React from 'react'; + +import { + shallow +} from 'enzyme'; + +import { DropZone } from '../DropZone'; + +describe('', function() { + + describe('#render', function() { + + it('should render', function() { + shallow(); + }); + + }); + + + describe('#handleDragOver', function() { + + it('should not render overlay during drag without file', function() { + + // given + const wrapper = shallow(); + + // when + const event = new MockDragEvent(); + + wrapper.simulate('dragover', event); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.false; + + }); + + + it('should not render overlay during drag with a GIF', function() { + + // given + const wrapper = shallow(); + + // when + const event = new MockDragEvent({ + type: 'image/gif', + kind: 'file' + }); + + wrapper.simulate('dragover', event); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.false; + + }); + + + it('should render overlay during drag with a file', function() { + + // given + const wrapper = shallow(); + + // when + const event = new MockDragEvent({ + type: '', + kind: 'file' + }); + + wrapper.simulate('dragover', event); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.true; + + }); + + }); + + + describe('#handleDragLeave', function() { + + it('should not render overlay when drag is over', function() { + + // given + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent({ + type: '', + kind: 'file' + }); + const dragLeaveEvent = new MockDragEvent({ + type: '', + kind: 'file' + }); + + dragLeaveEvent.relatedTarget = null; + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('dragleave', dragLeaveEvent); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.false; + + }); + + + it('should render overlay when dragging over elements', function() { + + // given + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent({ + type: '', + kind: 'file' + }); + const dragLeaveEvent = new MockDragEvent({ + type: '', + kind: 'file' + }); + + dragLeaveEvent.relatedTarget = document.createElement('div'); + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('dragleave', dragLeaveEvent); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.true; + + }); + + }); + + + describe('#handleDrop', function() { + + it('should not render overlay when file is dropped', function() { + + // given + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent({ + type: '', + kind: 'file' + }); + const dropEvent = new MockDragEvent({ + type: '', + kind: 'file' + }); + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('drop', dropEvent); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.false; + + }); + + + it('should not call passed onDrop prop with event if no file is dragged', function() { + + // given + const dropSpy = sinon.spy(); + + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent(); + const dropEvent = new MockDragEvent(); + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('drop', dropEvent); + + // then + expect(dropSpy).to.have.not.been.called; + + }); + + + it('should call passed onDrop prop with files', function() { + + // given + const dropSpy = sinon.spy(); + + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent({ + type: '', + kind: 'file' + }); + const dropEvent = new MockDragEvent({ + type: '', + kind: 'file' + }); + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('drop', dropEvent); + + // then + expect(dropSpy).to.be.calledOnce; + expect(dropSpy.getCall(0).args).to.have.lengthOf(1); + expect(dropSpy.getCall(0).args[0]).to.be.an('Array').with.lengthOf(1); + + }); + + }); + +}); + + + +// helper ///// +class MockDragEvent { + constructor(...items) { + this.dataTransfer = { + items, + files: items + }; + } + + preventDefault() {} + + stopPropagation() {} +} diff --git a/client/src/app/drop-zone/index.js b/client/src/app/drop-zone/index.js new file mode 100644 index 0000000000..185ac896a7 --- /dev/null +++ b/client/src/app/drop-zone/index.js @@ -0,0 +1 @@ +export { DropZone } from './DropZone'; diff --git a/client/src/app/tabs/xml/CodeMirror.js b/client/src/app/tabs/xml/CodeMirror.js index 2019c2d1ce..8629737a5d 100644 --- a/client/src/app/tabs/xml/CodeMirror.js +++ b/client/src/app/tabs/xml/CodeMirror.js @@ -31,6 +31,8 @@ export default function create(options) { el = _el; }, { autoCloseTags: true, + dragDrop: true, + allowDropFileTypes: ['text/plain'], lineWrapping: true, lineNumbers: true, mode: {