From f2c36a934ec8a6914a6cfa739568bcd076a514a8 Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 20 May 2016 06:20:35 -0700 Subject: [PATCH] Provide an `` control in Remarkup for mobile and users with esoteric windowing systems Summary: Ref T5187. This definitely feels a bit flimsy and I'm going to hold it until I cut the release since it changes a couple of things about Workflow in general, but it seems to work OK and most of it is fine. The intent is described in T5187#176236. In practice, most of that works like I describe, then the `phui-file-upload` behavior gets some weird glue to figure out if the input is part of the form. Not the most elegant system, but I think it'll hold until we come up with many reasons to write a lot more Javascript. Test Plan: Used both drag-and-drop and the upload dialog to upload files in Safari, Firefox and Chrome. {F1653716} Reviewers: chad Reviewed By: chad Maniphest Tasks: T5187 Differential Revision: https://secure.phabricator.com/D15953 --- src/__phutil_library_map__.php | 2 + .../PhabricatorFileDropUploadController.php | 14 +--- .../PhabricatorFileUploadDialogController.php | 49 ++++++++++-- .../files/storage/PhabricatorFile.php | 8 ++ src/view/form/control/PHUIFormFileControl.php | 44 ++++++++++ .../rsrc/externals/javelin/lib/Workflow.js | 63 ++++++++++++++- webroot/rsrc/js/core/DragAndDropFileUpload.js | 6 +- webroot/rsrc/js/core/TextAreaUtils.js | 20 +++++ .../core/behavior-drag-and-drop-textarea.js | 23 ++---- .../behavior-phabricator-remarkup-assist.js | 16 +++- .../rsrc/js/phui/behavior-phui-file-upload.js | 80 +++++++++++++++++++ 11 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 src/view/form/control/PHUIFormFileControl.php create mode 100644 webroot/rsrc/js/phui/behavior-phui-file-upload.js diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 7df48b8426..3749af3276 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1594,6 +1594,7 @@ phutil_register_library_map(array( 'PHUIFeedStoryExample' => 'applications/uiexample/examples/PHUIFeedStoryExample.php', 'PHUIFeedStoryView' => 'view/phui/PHUIFeedStoryView.php', 'PHUIFormDividerControl' => 'view/form/control/PHUIFormDividerControl.php', + 'PHUIFormFileControl' => 'view/form/control/PHUIFormFileControl.php', 'PHUIFormFreeformDateControl' => 'view/form/control/PHUIFormFreeformDateControl.php', 'PHUIFormIconSetControl' => 'view/form/control/PHUIFormIconSetControl.php', 'PHUIFormInsetView' => 'view/form/PHUIFormInsetView.php', @@ -5996,6 +5997,7 @@ phutil_register_library_map(array( 'PHUIFeedStoryExample' => 'PhabricatorUIExample', 'PHUIFeedStoryView' => 'AphrontView', 'PHUIFormDividerControl' => 'AphrontFormControl', + 'PHUIFormFileControl' => 'AphrontFormControl', 'PHUIFormFreeformDateControl' => 'AphrontFormControl', 'PHUIFormIconSetControl' => 'AphrontFormControl', 'PHUIFormInsetView' => 'AphrontView', diff --git a/src/applications/files/controller/PhabricatorFileDropUploadController.php b/src/applications/files/controller/PhabricatorFileDropUploadController.php index 222fc799c7..21714994ff 100644 --- a/src/applications/files/controller/PhabricatorFileDropUploadController.php +++ b/src/applications/files/controller/PhabricatorFileDropUploadController.php @@ -56,7 +56,7 @@ final class PhabricatorFileDropUploadController $file_phid = $result['filePHID']; if ($file_phid) { $file = $this->loadFile($file_phid); - $result += $this->getFileDictionary($file); + $result += $file->getDragAndDropDictionary(); } return id(new AphrontAjaxResponse())->setContent($result); @@ -84,7 +84,7 @@ final class PhabricatorFileDropUploadController } else { $result = array( 'complete' => true, - ) + $this->getFileDictionary($file); + ) + $file->getDragAndDropDictionary(); } return id(new AphrontAjaxResponse())->setContent($result); @@ -99,18 +99,10 @@ final class PhabricatorFileDropUploadController 'isExplicitUpload' => true, )); - $result = $this->getFileDictionary($file); + $result = $file->getDragAndDropDictionary(); return id(new AphrontAjaxResponse())->setContent($result); } - private function getFileDictionary(PhabricatorFile $file) { - return array( - 'id' => $file->getID(), - 'phid' => $file->getPHID(), - 'uri' => $file->getBestURI(), - ); - } - private function loadFile($file_phid) { $viewer = $this->getViewer(); diff --git a/src/applications/files/controller/PhabricatorFileUploadDialogController.php b/src/applications/files/controller/PhabricatorFileUploadDialogController.php index cf13f4d694..77fff2561f 100644 --- a/src/applications/files/controller/PhabricatorFileUploadDialogController.php +++ b/src/applications/files/controller/PhabricatorFileUploadDialogController.php @@ -6,12 +6,51 @@ final class PhabricatorFileUploadDialogController public function handleRequest(AphrontRequest $request) { $viewer = $request->getViewer(); - return $this->newDialog() - ->setTitle(pht('Upload File')) - ->appendChild(pht( - 'To add files, drag and drop them into the comment text area.')) - ->addCancelButton('/', pht('Close')); + $e_file = true; + $errors = array(); + if ($request->isDialogFormPost()) { + $file_phids = $request->getStrList('filePHIDs'); + if ($file_phids) { + $files = id(new PhabricatorFileQuery()) + ->setViewer($viewer) + ->withPHIDs($file_phids) + ->setRaisePolicyExceptions(true) + ->execute(); + } else { + $files = array(); + } + if ($files) { + $results = array(); + foreach ($files as $file) { + $results[] = $file->getDragAndDropDictionary(); + } + + $content = array( + 'files' => $results, + ); + + return id(new AphrontAjaxResponse())->setContent($content); + } else { + $e_file = pht('Required'); + $errors[] = pht('You must choose a file to upload.'); + } + } + + $form = id(new AphrontFormView()) + ->appendChild( + id(new PHUIFormFileControl()) + ->setName('filePHIDs') + ->setLabel(pht('Upload File')) + ->setAllowMultiple(true) + ->setError($e_file)); + + return $this->newDialog() + ->setTitle(pht('File')) + ->setErrors($errors) + ->appendForm($form) + ->addSubmitButton(pht('Upload')) + ->addCancelButton('/'); } } diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php index ff2a4d27ac..d545aa1de0 100644 --- a/src/applications/files/storage/PhabricatorFile.php +++ b/src/applications/files/storage/PhabricatorFile.php @@ -851,6 +851,14 @@ final class PhabricatorFile extends PhabricatorFileDAO return $supported; } + public function getDragAndDropDictionary() { + return array( + 'id' => $this->getID(), + 'phid' => $this->getPHID(), + 'uri' => $this->getBestURI(), + ); + } + public function instantiateStorageEngine() { return self::buildEngine($this->getStorageEngine()); } diff --git a/src/view/form/control/PHUIFormFileControl.php b/src/view/form/control/PHUIFormFileControl.php new file mode 100644 index 0000000000..57f5d30bf5 --- /dev/null +++ b/src/view/form/control/PHUIFormFileControl.php @@ -0,0 +1,44 @@ +allowMultiple = $allow_multiple; + return $this; + } + + public function getAllowMultiple() { + return $this->allowMultiple; + } + + protected function renderInput() { + $file_id = $this->getID(); + + Javelin::initBehavior( + 'phui-file-upload', + array( + 'fileInputID' => $file_id, + 'inputName' => $this->getName(), + 'uploadURI' => '/file/dropupload/', + 'chunkThreshold' => PhabricatorFileStorageEngine::getChunkThreshold(), + )); + + return phutil_tag( + 'input', + array( + 'type' => 'file', + 'multiple' => $this->getAllowMultiple() ? 'multiple' : null, + 'name' => $this->getName().'.raw', + 'id' => $file_id, + 'disabled' => $this->getDisabled() ? 'disabled' : null, + )); + } + +} diff --git a/webroot/rsrc/externals/javelin/lib/Workflow.js b/webroot/rsrc/externals/javelin/lib/Workflow.js index 50716b1c07..2ee7ef1ff0 100644 --- a/webroot/rsrc/externals/javelin/lib/Workflow.js +++ b/webroot/rsrc/externals/javelin/lib/Workflow.js @@ -25,7 +25,7 @@ JX.install('Workflow', { this.setData(data || {}); }, - events : ['error', 'finally', 'submit'], + events : ['error', 'finally', 'submit', 'start'], statics : { _stack : [], @@ -54,6 +54,9 @@ JX.install('Workflow', { } var workflow = new JX.Workflow(form.getAttribute('action'), {}); + + workflow._form = form; + workflow.setDataWithListOfPairs(pairs); workflow.setMethod(form.getAttribute('method')); workflow.listen('finally', function() { @@ -137,9 +140,14 @@ JX.install('Workflow', { data.push([button.name, button.value || true]); var active = JX.Workflow._getActiveWorkflow(); + + active._form = form; + var e = active.invoke('submit', {form: form, data: data}); if (!e.getStopped()) { - active._destroy(); + // NOTE: Don't remove the current dialog yet because additional + // handlers may still want to access the nodes. + active .setURI(form.getAttribute('action') || active.getURI()) .setDataWithListOfPairs(data) @@ -156,7 +164,41 @@ JX.install('Workflow', { _root : null, _pushed : false, _data : null, + + _form: null, + _paused: 0, + _nextCallback: null, + + getSourceForm: function() { + return this._form; + }, + + pause: function() { + this._paused++; + return this; + }, + + resume: function() { + if (!this._paused) { + JX.$E('Resuming a workflow which is not paused!'); + } + + this._paused--; + + if (!this._paused) { + var next = this._nextCallback; + this._nextCallback = null; + if (next) { + next(); + } + } + + return this; + }, + _onload : function(r) { + this._destroy(); + // It is permissible to send back a falsey redirect to force a page // reload, so we need to take this branch if the key is present. if (r && (typeof r.redirect != 'undefined')) { @@ -247,7 +289,19 @@ JX.install('Workflow', { this._root = null; } }, + start : function() { + var next = JX.bind(this, this._send); + + this.pause(); + this._nextCallback = next; + + this.invoke('start', this); + + this.resume(); + }, + + _send: function() { var uri = this.getURI(); var method = this.getMethod(); var r = new JX.Request(uri, JX.bind(this, this._onload)); @@ -291,6 +345,11 @@ JX.install('Workflow', { return this; }, + addData: function(key, value) { + this._data.push([key, value]); + return this; + }, + setDataWithListOfPairs : function(list_of_pairs) { this._data = list_of_pairs; return this; diff --git a/webroot/rsrc/js/core/DragAndDropFileUpload.js b/webroot/rsrc/js/core/DragAndDropFileUpload.js index 08cda15798..644f705965 100644 --- a/webroot/rsrc/js/core/DragAndDropFileUpload.js +++ b/webroot/rsrc/js/core/DragAndDropFileUpload.js @@ -155,7 +155,7 @@ JX.install('PhabricatorDragAndDropFileUpload', { var files = e.getRawEvent().dataTransfer.files; for (var ii = 0; ii < files.length; ii++) { - this._sendRequest(files[ii]); + this.sendRequest(files[ii]); } // Force depth to 0. @@ -216,7 +216,7 @@ JX.install('PhabricatorDragAndDropFileUpload', { if (!spec.name) { spec.name = 'pasted_file'; } - this._sendRequest(spec); + this.sendRequest(spec); } })); } @@ -224,7 +224,7 @@ JX.install('PhabricatorDragAndDropFileUpload', { this.setIsEnabled(true); }, - _sendRequest : function(spec) { + sendRequest : function(spec) { var file = new JX.PhabricatorFileUpload() .setRawFileObject(spec) .setName(spec.name) diff --git a/webroot/rsrc/js/core/TextAreaUtils.js b/webroot/rsrc/js/core/TextAreaUtils.js index 8f8a81067b..56f0789a46 100644 --- a/webroot/rsrc/js/core/TextAreaUtils.js +++ b/webroot/rsrc/js/core/TextAreaUtils.js @@ -62,6 +62,26 @@ JX.install('TextAreaUtils', { JX.TextAreaUtils.setSelectionRange(area, start, end); }, + + /** + * Insert a reference to a given uploaded file into a textarea. + */ + insertFileReference: function(area, file) { + var ref = '{F' + file.getID() + '}'; + + // If we're inserting immediately after a "}" (usually, another file + // reference), put some newlines before our token so that multiple file + // uploads get laid out more nicely. + var range = JX.TextAreaUtils.getSelectionRange(area); + var before = area.value.substring(0, range.start); + if (before.match(/\}$/)) { + ref = '\n\n' + ref; + } + + JX.TextAreaUtils.setSelectionText(area, ref, false); + }, + + /** * Get the document pixel positions of the beginning and end of a character * range in a textarea. diff --git a/webroot/rsrc/js/core/behavior-drag-and-drop-textarea.js b/webroot/rsrc/js/core/behavior-drag-and-drop-textarea.js index 7ee536edb4..4493ae3b2b 100644 --- a/webroot/rsrc/js/core/behavior-drag-and-drop-textarea.js +++ b/webroot/rsrc/js/core/behavior-drag-and-drop-textarea.js @@ -10,32 +10,23 @@ JX.behavior('aphront-drag-and-drop-textarea', function(config) { var target = JX.$(config.target); - function onupload(f) { - var ref = '{F' + f.getID() + '}'; - - // If we're inserting immediately after a "}" (usually, another file - // reference), put some newlines before our token so that multiple file - // uploads get laid out more nicely. - var range = JX.TextAreaUtils.getSelectionRange(target); - var before = target.value.substring(0, range.start); - if (before.match(/\}$/)) { - ref = '\n\n' + ref; - } - - JX.TextAreaUtils.setSelectionText(target, ref, false); - } - if (JX.PhabricatorDragAndDropFileUpload.isSupported()) { var drop = new JX.PhabricatorDragAndDropFileUpload(target) .setURI(config.uri) .setChunkThreshold(config.chunkThreshold); + drop.listen('didBeginDrag', function() { JX.DOM.alterClass(target, config.activatedClass, true); }); + drop.listen('didEndDrag', function() { JX.DOM.alterClass(target, config.activatedClass, false); }); - drop.listen('didUpload', onupload); + + drop.listen('didUpload', function(file) { + JX.TextAreaUtils.insertFileReference(target, file); + }); + drop.start(); } diff --git a/webroot/rsrc/js/core/behavior-phabricator-remarkup-assist.js b/webroot/rsrc/js/core/behavior-phabricator-remarkup-assist.js index f09070e42e..2b1646968c 100644 --- a/webroot/rsrc/js/core/behavior-phabricator-remarkup-assist.js +++ b/webroot/rsrc/js/core/behavior-phabricator-remarkup-assist.js @@ -194,7 +194,21 @@ JX.behavior('phabricator-remarkup-assist', function(config) { .start(); break; case 'fa-cloud-upload': - new JX.Workflow('/file/uploaddialog/').start(); + new JX.Workflow('/file/uploaddialog/') + .setHandler(function(response) { + var files = response.files; + for (var ii = 0; ii < files.length; ii++) { + var file = files[ii]; + + var upload = new JX.PhabricatorFileUpload() + .setID(file.id) + .setPHID(file.phid) + .setURI(file.uri); + + JX.TextAreaUtils.insertFileReference(area, upload); + } + }) + .start(); break; case 'fa-arrows-alt': if (edit_mode == 'fa-arrows-alt') { diff --git a/webroot/rsrc/js/phui/behavior-phui-file-upload.js b/webroot/rsrc/js/phui/behavior-phui-file-upload.js new file mode 100644 index 0000000000..39e3bddff6 --- /dev/null +++ b/webroot/rsrc/js/phui/behavior-phui-file-upload.js @@ -0,0 +1,80 @@ +/** + * @provides javelin-behavior-phui-file-upload + * @requires javelin-behavior + * javelin-stratcom + * javelin-dom + * phuix-dropdown-menu + */ + +JX.behavior('phui-file-upload', function(config) { + + function startUpload(workflow, input) { + var files = input.files; + + if (!files || !files.length) { + return; + } + + var state = { + workflow: workflow, + input: input, + waiting: 0, + phids: [] + }; + + var callback = JX.bind(null, didUpload, state); + + var dummy = input; + var uploader = new JX.PhabricatorDragAndDropFileUpload(dummy) + .setURI(config.uploadURI) + .setChunkThreshold(config.chunkThreshold); + + uploader.listen('didUpload', callback); + uploader.start(); + + workflow.pause(); + for (var ii = 0; ii < files.length; ii++) { + state.waiting++; + uploader.sendRequest(files[ii]); + } + } + + function didUpload(state, file) { + state.phids.push(file.getPHID()); + state.waiting--; + + if (state.waiting) { + return; + } + + state.workflow + .addData(config.inputName, state.phids.join(', ')) + .resume(); + } + + JX.Workflow.listen('start', function(workflow) { + var form = workflow.getSourceForm(); + if (!form) { + return; + } + + var input; + try { + input = JX.$(config.fileInputID); + } catch (ex) { + return; + } + + var local_form = JX.DOM.findAbove(input, 'form'); + if (!local_form) { + return; + } + + if (local_form !== form) { + return; + } + + startUpload(workflow, input); + }); + +});