1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-27 07:50:57 +01:00

Provide an <input type="file"> 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
This commit is contained in:
epriestley 2016-05-20 06:20:35 -07:00
parent 804a5db41a
commit f2c36a934e
11 changed files with 287 additions and 38 deletions

View file

@ -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',

View file

@ -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();

View file

@ -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('/');
}
}

View file

@ -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());
}

View file

@ -0,0 +1,44 @@
<?php
final class PHUIFormFileControl
extends AphrontFormControl {
private $allowMultiple;
protected function getCustomControlClass() {
return 'phui-form-file-upload';
}
public function setAllowMultiple($allow_multiple) {
$this->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,
));
}
}

View file

@ -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;

View file

@ -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)

View file

@ -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.

View file

@ -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();
}

View file

@ -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') {

View file

@ -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);
});
});