diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -420,6 +420,7 @@ 'rsrc/js/application/repository/repository-crossreference.js' => 'e5339c43', 'rsrc/js/application/search/behavior-reorder-queries.js' => 'e9581f08', 'rsrc/js/application/slowvote/behavior-slowvote-embed.js' => '887ad43f', + 'rsrc/js/application/transactions/behavior-comment-actions.js' => 'f2c64202', 'rsrc/js/application/transactions/behavior-reorder-fields.js' => 'b59e1e96', 'rsrc/js/application/transactions/behavior-show-older-transactions.js' => 'dbbf48b6', 'rsrc/js/application/transactions/behavior-transaction-comment-form.js' => 'b23b49e6', @@ -498,6 +499,8 @@ 'rsrc/js/phuix/PHUIXActionListView.js' => 'b5c256b8', 'rsrc/js/phuix/PHUIXActionView.js' => '8cf6d262', 'rsrc/js/phuix/PHUIXDropdownMenu.js' => 'bd4c8dca', + 'rsrc/js/phuix/PHUIXFormControl.js' => 'f9fba5ee', + 'rsrc/js/phuix/PHUIXIconView.js' => 'bff6884b', ), 'symbols' => array( 'almanac-css' => 'dbb9b3af', @@ -561,6 +564,7 @@ 'javelin-behavior-audit-preview' => 'd835b03a', 'javelin-behavior-bulk-job-reload' => 'edf8a145', 'javelin-behavior-choose-control' => '6153c708', + 'javelin-behavior-comment-actions' => 'f2c64202', 'javelin-behavior-config-reorder-fields' => 'b6993408', 'javelin-behavior-conpherence-drag-and-drop-photo' => 'cf86d16a', 'javelin-behavior-conpherence-menu' => '1d45c74d', @@ -823,6 +827,8 @@ 'phuix-action-list-view' => 'b5c256b8', 'phuix-action-view' => '8cf6d262', 'phuix-dropdown-menu' => 'bd4c8dca', + 'phuix-form-control-view' => 'f9fba5ee', + 'phuix-icon-view' => 'bff6884b', 'policy-css' => '957ea14c', 'policy-edit-css' => '815c66f7', 'policy-transaction-detail-css' => '82100a43', @@ -1767,6 +1773,10 @@ 'javelin-util', 'javelin-request', ), + 'bff6884b' => array( + 'javelin-install', + 'javelin-dom', + ), 'c1700f6f' => array( 'javelin-install', 'javelin-util', @@ -1973,6 +1983,14 @@ 'javelin-workflow', 'javelin-json', ), + 'f2c64202' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-workflow', + 'javelin-dom', + 'phuix-form-control-view', + 'phuix-icon-view', + ), 'f36e01af' => array( 'javelin-behavior', 'javelin-behavior-device', @@ -2029,6 +2047,10 @@ 'javelin-util', 'phabricator-busy', ), + 'f9fba5ee' => array( + 'javelin-install', + 'javelin-dom', + ), 'fa0f4fc2' => array( 'javelin-behavior', 'javelin-dom', diff --git a/src/applications/paste/controller/PhabricatorPasteViewController.php b/src/applications/paste/controller/PhabricatorPasteViewController.php --- a/src/applications/paste/controller/PhabricatorPasteViewController.php +++ b/src/applications/paste/controller/PhabricatorPasteViewController.php @@ -34,6 +34,7 @@ ->setViewer($viewer) ->withIDs(array($id)) ->needContent(true) + ->needRawContent(true) ->executeOne(); if (!$paste) { return new Aphront404Response(); diff --git a/src/applications/project/editor/PhabricatorProjectsEditEngineExtension.php b/src/applications/project/editor/PhabricatorProjectsEditEngineExtension.php --- a/src/applications/project/editor/PhabricatorProjectsEditEngineExtension.php +++ b/src/applications/project/editor/PhabricatorProjectsEditEngineExtension.php @@ -53,6 +53,7 @@ pht('Add projects.'), pht('Remove projects.'), pht('Set associated projects, overwriting current value.')) + ->setCommentActionLabel(pht('Add Projects')) ->setTransactionType($edge_type) ->setMetadataValue('edge:type', $project_edge_type) ->setValue($project_phids); diff --git a/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditEngineExtension.php b/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditEngineExtension.php --- a/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditEngineExtension.php +++ b/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditEngineExtension.php @@ -50,6 +50,7 @@ pht('Add subscribers.'), pht('Remove subscribers.'), pht('Set subscribers, overwriting current value.')) + ->setCommentActionLabel(pht('Add Subscribers')) ->setTransactionType($subscribers_type) ->setValue($sub_phids); diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php --- a/src/applications/transactions/editengine/PhabricatorEditEngine.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php @@ -873,6 +873,8 @@ } final public function buildEditEngineCommentView($object) { + $config = $this->loadEditEngineConfiguration(null); + $viewer = $this->getViewer(); $object_phid = $object->getPHID(); @@ -897,6 +899,19 @@ $view->setCurrentVersion($this->loadDraftVersion($object)); + $fields = $this->buildEditFields($object); + + $all_types = array(); + foreach ($fields as $field) { + // TODO: Load draft stuff. + $types = $field->getCommentEditTypes(); + foreach ($types as $type) { + $all_types[] = $type; + } + } + + $view->setEditTypes($all_types); + return $view; } @@ -999,6 +1014,9 @@ return new Aphront400Response(); } + $config = $this->loadEditEngineConfiguration(null); + $fields = $this->buildEditFields($object); + $is_preview = $request->isPreviewRequest(); $view_uri = $this->getObjectViewURI($object); @@ -1025,11 +1043,46 @@ $xactions = array(); - $xactions[] = id(clone $template) - ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) - ->attachComment( - id(clone $comment_template) - ->setContent($comment_text)); + $actions = $request->getStr('editengine.actions'); + if ($actions) { + $type_map = array(); + foreach ($fields as $field) { + $types = $field->getCommentEditTypes(); + foreach ($types as $type) { + $type_map[$type->getEditType()] = $type; + } + } + + $actions = phutil_json_decode($actions); + foreach ($actions as $action) { + $type = idx($action, 'type'); + if (!$type) { + continue; + } + + $edit_type = idx($type_map, $type); + if (!$edit_type) { + continue; + } + + $type_xactions = $edit_type->generateTransactions( + $template, + array( + 'value' => idx($action, 'value'), + )); + foreach ($type_xactions as $type_xaction) { + $xactions[] = $type_xaction; + } + } + } + + if (strlen($comment_text) || !$xactions) { + $xactions[] = id(clone $template) + ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) + ->attachComment( + id(clone $comment_template) + ->setContent($comment_text)); + } $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) diff --git a/src/applications/transactions/editfield/PhabricatorEditField.php b/src/applications/transactions/editfield/PhabricatorEditField.php --- a/src/applications/transactions/editfield/PhabricatorEditField.php +++ b/src/applications/transactions/editfield/PhabricatorEditField.php @@ -494,4 +494,8 @@ return array($edit_type); } + public function getCommentEditTypes() { + return array(); + } + } diff --git a/src/applications/transactions/editfield/PhabricatorTokenizerEditField.php b/src/applications/transactions/editfield/PhabricatorTokenizerEditField.php --- a/src/applications/transactions/editfield/PhabricatorTokenizerEditField.php +++ b/src/applications/transactions/editfield/PhabricatorTokenizerEditField.php @@ -3,8 +3,19 @@ abstract class PhabricatorTokenizerEditField extends PhabricatorPHIDListEditField { + private $commentActionLabel; + abstract protected function newDatasource(); + public function setCommentActionLabel($label) { + $this->commentActionLabel = $label; + return $this; + } + + public function getCommentActionLabel() { + return $this->commentActionLabel; + } + protected function newControl() { $control = id(new AphrontFormTokenizerControl()) ->setDatasource($this->newDatasource()); @@ -21,4 +32,42 @@ return $request->getArr($key.'.original'); } + protected function newEditType() { + $type = parent::newEditType(); + + if ($this->getUseEdgeTransactions()) { + $datasource = $this->newDatasource() + ->setViewer($this->getViewer()); + $type->setDatasource($datasource); + } + + return $type; + } + + public function getCommentEditTypes() { + if (!$this->getUseEdgeTransactions()) { + return parent::getCommentEditTypes(); + } + + $transaction_type = $this->getTransactionType(); + if ($transaction_type === null) { + return array(); + } + + $label = $this->getCommentActionLabel(); + if ($label === null) { + return array(); + } + + $type_key = $this->getEditTypeKey(); + $base = $this->getEditType(); + + $add = id(clone $base) + ->setEditType($type_key.'.add') + ->setEdgeOperation('+') + ->setLabel($label); + + return array($add); + } + } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -811,7 +811,17 @@ $this->adjustTransactionValues($object, $xaction); } - $xactions = $this->filterTransactions($object, $xactions); + try { + $xactions = $this->filterTransactions($object, $xactions); + } catch (Exception $ex) { + if ($read_locking) { + $object->endReadLocking(); + } + if ($transaction_open) { + $object->killTransaction(); + } + throw $ex; + } // Now that we've merged, filtered, and combined transactions, check for // required capabilities. diff --git a/src/applications/transactions/edittype/PhabricatorEdgeEditType.php b/src/applications/transactions/edittype/PhabricatorEdgeEditType.php --- a/src/applications/transactions/edittype/PhabricatorEdgeEditType.php +++ b/src/applications/transactions/edittype/PhabricatorEdgeEditType.php @@ -4,6 +4,7 @@ private $edgeOperation; private $valueDescription; + private $datasource; public function setEdgeOperation($edge_operation) { $this->edgeOperation = $edge_operation; @@ -14,6 +15,15 @@ return $this->edgeOperation; } + public function setDatasource($datasource) { + $this->datasource = $datasource; + return $this; + } + + public function getDatasource() { + return $this->datasource; + } + public function getValueType() { return 'list'; } @@ -46,4 +56,33 @@ return $this->valueDescription; } + public function getPHUIXControlType() { + $datasource = $this->getDatasource(); + + if (!$datasource) { + return null; + } + + return 'tokenizer'; + } + + public function getPHUIXControlSpecification() { + $datasource = $this->getDatasource(); + + if (!$datasource) { + return null; + } + + $template = new AphrontTokenizerTemplateView(); + + return array( + 'markup' => $template->render(), + 'config' => array( + 'src' => $datasource->getDatasourceURI(), + 'browseURI' => $datasource->getBrowseURI(), + 'placeholder' => $datasource->getPlaceholderText(), + ), + ); + } + } diff --git a/src/applications/transactions/edittype/PhabricatorEditType.php b/src/applications/transactions/edittype/PhabricatorEditType.php --- a/src/applications/transactions/edittype/PhabricatorEditType.php +++ b/src/applications/transactions/edittype/PhabricatorEditType.php @@ -4,6 +4,7 @@ private $editType; private $transactionType; + private $label; private $field; private $description; private $summary; @@ -30,6 +31,15 @@ return $this->summary; } + public function setLabel($label) { + $this->label = $label; + return $this; + } + + public function getLabel() { + return $this->label; + } + public function setField(PhabricatorEditField $field) { $this->field = $field; return $this; @@ -86,4 +96,12 @@ return $xaction; } + public function getPHUIXControlType() { + return null; + } + + public function getPHUIXControlSpecification() { + return null; + } + } diff --git a/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php b/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php --- a/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php +++ b/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php @@ -22,6 +22,7 @@ private $currentVersion; private $versionedDraft; + private $editTypes; public function setObjectPHID($object_phid) { $this->objectPHID = $object_phid; @@ -100,6 +101,15 @@ return $this; } + public function setEditTypes($edit_types) { + $this->editTypes = $edit_types; + return $this; + } + + public function getEditTypes() { + return $this->editTypes; + } + public function render() { $user = $this->getUser(); @@ -182,7 +192,7 @@ $version_key = PhabricatorVersionedDraft::KEY_VERSION; $version_value = $this->getCurrentVersion(); - return id(new AphrontFormView()) + $form = id(new AphrontFormView()) ->setUser($this->getUser()) ->addSigil('transaction-append') ->setWorkflow(true) @@ -193,7 +203,57 @@ ->setAction($this->getAction()) ->setID($this->getFormID()) ->addHiddenInput('__draft__', $draft_key) - ->addHiddenInput($version_key, $version_value) + ->addHiddenInput($version_key, $version_value); + + $edit_types = $this->getEditTypes(); + if ($edit_types) { + + $action_map = array(); + foreach ($edit_types as $edit_type) { + $key = $edit_type->getEditType(); + $action_map[$key] = array( + 'key' => $key, + 'label' => $edit_type->getLabel(), + 'type' => $edit_type->getPHUIXControlType(), + 'spec' => $edit_type->getPHUIXControlSpecification(), + ); + } + + $options = array(); + $options['+'] = pht('Add Action...'); + foreach ($action_map as $key => $item) { + $options[$key] = $item['label']; + } + + $action_id = celerity_generate_unique_node_id(); + $input_id = celerity_generate_unique_node_id(); + + $form->appendChild( + phutil_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => 'editengine.actions', + 'id' => $input_id, + ))); + + $form->appendChild( + id(new AphrontFormSelectControl()) + ->setLabel(pht('Actions')) + ->setID($action_id) + ->setOptions($options)); + + Javelin::initBehavior( + 'comment-actions', + array( + 'actionID' => $action_id, + 'inputID' => $input_id, + 'formID' => $this->getFormID(), + 'actions' => $action_map, + )); + } + + $form ->appendChild( id(new PhabricatorRemarkupControl()) ->setID($this->getCommentID()) @@ -207,6 +267,8 @@ ->appendChild( id(new AphrontFormMarkupControl()) ->setValue($status)); + + return $form; } private function renderPreviewPanel() { diff --git a/webroot/rsrc/js/application/transactions/behavior-comment-actions.js b/webroot/rsrc/js/application/transactions/behavior-comment-actions.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/application/transactions/behavior-comment-actions.js @@ -0,0 +1,84 @@ +/** + * @provides javelin-behavior-comment-actions + * @requires javelin-behavior + * javelin-stratcom + * javelin-workflow + * javelin-dom + * phuix-form-control-view + * phuix-icon-view + */ + +JX.behavior('comment-actions', function(config) { + var action_map = config.actions; + + var action_node = JX.$(config.actionID); + var form_node = JX.$(config.formID); + var input_node = JX.$(config.inputID); + + var rows = {}; + + JX.DOM.listen(action_node, 'change', null, function() { + var options = action_node.options; + var option; + + var selected = action_node.value; + action_node.value = '+'; + + for (var ii = 0; ii < options.length; ii++) { + option = options[ii]; + if (option.value == selected) { + add_row(option); + break; + } + } + }); + + JX.DOM.listen(form_node, 'submit', null, function() { + var data = []; + + for (var k in rows) { + data.push({ + type: k, + value: rows[k].getValue() + }); + } + + input_node.value = JX.JSON.stringify(data); + }); + + function add_row(option) { + var action = action_map[option.value]; + if (!action) { + return; + } + + option.disabled = true; + + var icon = new JX.PHUIXIconView() + .setIcon('fa-times-circle'); + var remove = JX.$N('a', {href: '#'}, icon.getNode()); + + var control = new JX.PHUIXFormControl() + .setLabel(action.label) + .setError(remove) + .setControl('tokenizer', action.spec); + var node = control.getNode(); + + rows[action.key] = control; + + JX.DOM.listen(remove, 'click', null, function(e) { + e.kill(); + JX.DOM.remove(node); + delete rows[action.key]; + option.disabled = false; + }); + + // TODO: Grotesque. + action_node + .parentNode + .parentNode + .parentNode + .insertBefore(node, action_node.parentNode.parentNode.nextSibling); + } + +}); diff --git a/webroot/rsrc/js/phuix/PHUIXFormControl.js b/webroot/rsrc/js/phuix/PHUIXFormControl.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/phuix/PHUIXFormControl.js @@ -0,0 +1,133 @@ +/** + * @provides phuix-form-control-view + * @requires javelin-install + * javelin-dom + */ + +JX.install('PHUIXFormControl', { + + members: { + _node: null, + _labelNode: null, + _errorNode: null, + _inputNode: null, + _valueSetCallback: null, + _valueGetCallback: null, + + setLabel: function(label) { + JX.DOM.setContent(this._getLabelNode(), label); + return this; + }, + + setError: function(error) { + JX.DOM.setContent(this._getErrorNode(), error); + return this; + }, + + setControl: function(type, spec) { + var node = this._getInputNode(); + + var input; + switch (type) { + case 'tokenizer': + input = this._newTokenizer(spec); + break; + default: + // TODO: Default or better error? + JX.$E('Bad Input Type'); + return; + } + + JX.DOM.setContent(node, input.node); + this._valueGetCallback = input.get; + this._valueSetCallback = input.set; + + return this; + }, + + setValue: function(value) { + this._valueSetCallback(value); + return this; + }, + + getValue: function() { + return this._valueGetCallback(); + }, + + getNode: function() { + if (!this._node) { + + var attrs = { + className: 'aphront-form-control grouped' + }; + + var content = [ + this._getLabelNode(), + this._getErrorNode(), + this._getInputNode() + ]; + + this._node = JX.$N('div', attrs, content); + } + + return this._node; + }, + + _getLabelNode: function() { + if (!this._labelNode) { + var attrs = { + className: 'aphront-form-label' + }; + + this._labelNode = JX.$N('label', attrs); + } + + return this._labelNode; + }, + + _getErrorNode: function() { + if (!this._errorNode) { + var attrs = { + className: 'aphront-form-error' + }; + + this._errorNode = JX.$N('span', attrs); + } + + return this._errorNode; + }, + + _getInputNode: function() { + if (!this._inputNode) { + var attrs = { + className: 'aphront-form-input' + }; + + this._inputNode = JX.$N('div', attrs); + } + + return this._inputNode; + }, + + _newTokenizer: function(spec) { + var build = JX.Prefab.newTokenizerFromTemplate( + spec.markup, + spec.config); + build.tokenizer.start(); + + return { + node: build.node, + get: function() { + return JX.keys(build.tokenizer.getTokens()); + }, + set: function(map) { + for (var k in map) { + build.tokenizer.addToken(k, map[k]); + } + } + }; + } + + } + +}); diff --git a/webroot/rsrc/js/phuix/PHUIXIconView.js b/webroot/rsrc/js/phuix/PHUIXIconView.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/phuix/PHUIXIconView.js @@ -0,0 +1,47 @@ +/** + * @provides phuix-icon-view + * @requires javelin-install + * javelin-dom + */ + +JX.install('PHUIXIconView', { + + members: { + _node: null, + _icon: null, + _color: null, + + setIcon: function(icon) { + var node = this.getNode(); + if (this._icon) { + JX.DOM.alterClass(node, this._icon, false); + } + this._icon = icon; + JX.DOM.alterClass(node, this._icon, true); + return this; + }, + + setColor: function(color) { + var node = this.getNode(); + if (this._color) { + JX.DOM.alterClass(node, this._color, false); + } + this._color = color; + JX.DOM.alterClass(node, this._color, true); + return this; + }, + + getNode: function() { + if (!this._node) { + var attrs = { + className: 'phui-icon-view phui-font-fa' + }; + + this._node = JX.$N('span', attrs); + } + + return this._node; + } + } + +});