diff --git a/src/applications/maniphest/auxiliaryfield/ManiphestAuxiliaryFieldDefaultSpecification.php b/src/applications/maniphest/auxiliaryfield/ManiphestAuxiliaryFieldDefaultSpecification.php
index b0818a22e6..41d3009618 100644
--- a/src/applications/maniphest/auxiliaryfield/ManiphestAuxiliaryFieldDefaultSpecification.php
+++ b/src/applications/maniphest/auxiliaryfield/ManiphestAuxiliaryFieldDefaultSpecification.php
@@ -1,294 +1,383 @@
fieldType;
}
public function setFieldType($val) {
$this->fieldType = $val;
return $this;
}
public function getError() {
return $this->error;
}
public function setError($val) {
$this->error = $val;
return $this;
}
public function getSelectOptions() {
return $this->selectOptions;
}
public function setSelectOptions($array) {
$this->selectOptions = $array;
return $this;
}
public function setRequired($bool) {
$this->required = $bool;
return $this;
}
public function isRequired() {
return $this->required;
}
public function setCheckboxLabel($checkbox_label) {
$this->checkboxLabel = $checkbox_label;
return $this;
}
public function getCheckboxLabel() {
return $this->checkboxLabel;
}
public function setCheckboxValue($checkbox_value) {
$this->checkboxValue = $checkbox_value;
return $this;
}
public function getCheckboxValue() {
return $this->checkboxValue;
}
public function renderControl() {
$control = null;
$type = $this->getFieldType();
switch ($type) {
case self::TYPE_INT:
$control = new AphrontFormTextControl();
break;
case self::TYPE_STRING:
$control = new AphrontFormTextControl();
break;
case self::TYPE_SELECT:
$control = new AphrontFormSelectControl();
$control->setOptions($this->getSelectOptions());
break;
case self::TYPE_BOOL:
$control = new AphrontFormCheckboxControl();
break;
case self::TYPE_DATE:
$control = new AphrontFormDateControl();
$control->setUser($this->getUser());
break;
case self::TYPE_REMARKUP:
$control = new PhabricatorRemarkupControl();
$control->setUser($this->getUser());
break;
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ $control = new AphrontFormTokenizerControl();
+ $control->setDatasource('/typeahead/common/users/');
+ if ($type == self::TYPE_USER) {
+ $control->setLimit(1);
+ }
+ break;
default:
$label = $this->getLabel();
throw new ManiphestAuxiliaryFieldTypeException(
"Field type '{$type}' is not a valid type (for field '{$label}').");
break;
}
switch ($type) {
case self::TYPE_BOOL:
$control->addCheckbox(
'auxiliary['.$this->getAuxiliaryKey().']',
1,
$this->getCheckboxLabel(),
(bool)$this->getValue());
break;
case self::TYPE_DATE:
$control->setValue($this->getValue());
$control->setName('auxiliary_date_'.$this->getAuxiliaryKey());
break;
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ $control->setName('auxiliary_tokenizer_'.$this->getAuxiliaryKey());
+ $value = array();
+ foreach ($this->getValue() as $phid) {
+ $value[$phid] = $this->getHandle($phid)->getFullName();
+ }
+ $control->setValue($value);
+ break;
default:
$control->setValue($this->getValue());
$control->setName('auxiliary['.$this->getAuxiliaryKey().']');
break;
}
$control->setLabel($this->getLabel());
$control->setCaption($this->getCaption());
$control->setError($this->getError());
return $control;
}
public function setValueFromRequest(AphrontRequest $request) {
- switch ($this->getFieldType()) {
+ $type = $this->getFieldType();
+ switch ($type) {
case self::TYPE_DATE:
$control = $this->renderControl();
$value = $control->readValueFromRequest($request);
break;
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ $name = 'auxiliary_tokenizer_'.$this->getAuxiliaryKey();
+ $value = $request->getArr($name);
+ if ($type == self::TYPE_USER) {
+ $value = array_slice($value, 0, 1);
+ }
+ break;
default:
$aux_post_values = $request->getArr('auxiliary');
$value = idx($aux_post_values, $this->getAuxiliaryKey(), '');
break;
}
return $this->setValue($value);
}
public function getValueForStorage() {
- return $this->getValue();
+ switch ($this->getFieldType()) {
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ return json_encode($this->getValue());
+ default:
+ return $this->getValue();
+ }
}
public function setValueFromStorage($value) {
+ switch ($this->getFieldType()) {
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ $value = json_decode($value, true);
+ if (!is_array($value)) {
+ $value = array();
+ }
+ break;
+ default:
+ break;
+ }
return $this->setValue($value);
}
public function validate() {
switch ($this->getFieldType()) {
case self::TYPE_INT:
- if (!is_numeric($this->getValue())) {
+ if ($this->getValue() && !is_numeric($this->getValue())) {
throw new ManiphestAuxiliaryFieldValidationException(
pht(
'%s must be an integer value.',
$this->getLabel()));
}
break;
case self::TYPE_BOOL:
return true;
case self::TYPE_STRING:
return true;
case self::TYPE_SELECT:
return true;
case self::TYPE_DATE:
- if ($this->getValue() <= 0) {
+ if ((int)$this->getValue() <= 0) {
throw new ManiphestAuxiliaryFieldValidationException(
pht(
'%s must be a valid date.',
$this->getLabel()));
}
break;
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ if (!is_array($this->getValue())) {
+ throw new ManiphestAuxiliaryFieldValidationException(
+ pht(
+ '%s is not a valid list of user PHIDs.',
+ $this->getLabel()));
+ }
+ break;
}
}
public function setDefaultValue($value) {
switch ($this->getFieldType()) {
case self::TYPE_DATE:
$value = strtotime($value);
if ($value <= 0) {
$value = time();
}
$this->setValue($value);
break;
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ if (!is_array($value)) {
+ $value = array();
+ } else {
+ $value = array_values($value);
+ }
+ $this->setValue($value);
+ break;
default:
$this->setValue((string)$value);
break;
}
}
public function getMarkupFields() {
switch ($this->getFieldType()) {
case self::TYPE_REMARKUP:
return array('default');
}
return parent::getMarkupFields();
}
public function renderForDetailView() {
switch ($this->getFieldType()) {
case self::TYPE_BOOL:
if ($this->getValue()) {
return $this->getCheckboxValue();
} else {
return null;
}
case self::TYPE_SELECT:
return idx($this->getSelectOptions(), $this->getValue());
case self::TYPE_DATE:
return phabricator_datetime($this->getValue(), $this->getUser());
case self::TYPE_REMARKUP:
return $this->getMarkupEngine()->getOutput(
$this,
'default');
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ return $this->renderHandleList($this->getValue());
}
return parent::renderForDetailView();
}
+ public function getRequiredHandlePHIDs() {
+ switch ($this->getFieldType()) {
+ case self::TYPE_USER;
+ case self::TYPE_USERS:
+ return $this->getValue();
+ }
+ return parent::getRequiredHandlePHIDs();
+ }
+
+ protected function renderHandleList(array $phids) {
+ $links = array();
+ foreach ($phids as $phid) {
+ $links[] = $this->getHandle($phid)->renderLink();
+ }
+ return phutil_implode_html(', ', $links);
+ }
+
public function renderTransactionDescription(
ManiphestTransaction $transaction,
$target) {
$label = $this->getLabel();
$old = $transaction->getOldValue();
$new = $transaction->getNewValue();
switch ($this->getFieldType()) {
case self::TYPE_BOOL:
if ($new) {
$desc = "set field '{$label}' true";
} else {
$desc = "set field '{$label}' false";
}
break;
case self::TYPE_SELECT:
$old_display = idx($this->getSelectOptions(), $old);
$new_display = idx($this->getSelectOptions(), $new);
if ($old === null) {
$desc = "set field '{$label}' to '{$new_display}'";
} else {
$desc = "changed field '{$label}' ".
"from '{$old_display}' to '{$new_display}'";
}
break;
case self::TYPE_DATE:
$new_display = phabricator_datetime($new, $this->getUser());
if ($old === null) {
$desc = "set field '{$label}' to '{$new_display}'";
} else {
$old_display = phabricator_datetime($old, $this->getUser());
$desc = "changed field '{$label}' ".
"from '{$old_display}' to '{$new_display}'";
}
break;
case self::TYPE_REMARKUP:
// TODO: After we get ApplicationTransactions, straighten this out.
$desc = "updated field '{$label}'";
break;
+ case self::TYPE_USER:
+ case self::TYPE_USERS:
+ // TODO: As above, this is a mess that should get straightened out,
+ // but it will be easier after T2217.
+ $desc = "updated field '{$label}'";
+ break;
default:
if (!strlen($old)) {
if (!strlen($new)) {
return null;
}
$desc = "set field '{$label}' to '{$new}'";
} else {
$desc = "updated '{$label}' ".
"from '{$old}' to '{$new}'";
}
break;
}
return $desc;
}
public function setShouldCopyWhenCreatingSimilarTask($copy) {
$this->shouldCopyWhenCreatingSimilarTask = $copy;
return $this;
}
public function shouldCopyWhenCreatingSimilarTask() {
return $this->shouldCopyWhenCreatingSimilarTask;
}
}
diff --git a/src/applications/maniphest/auxiliaryfield/ManiphestAuxiliaryFieldSpecification.php b/src/applications/maniphest/auxiliaryfield/ManiphestAuxiliaryFieldSpecification.php
index 6d1f8d89ad..7cc76ea8e5 100644
--- a/src/applications/maniphest/auxiliaryfield/ManiphestAuxiliaryFieldSpecification.php
+++ b/src/applications/maniphest/auxiliaryfield/ManiphestAuxiliaryFieldSpecification.php
@@ -1,211 +1,231 @@
task = $task;
return $this;
}
public function getTask() {
return $this->task;
}
public function setUser(PhabricatorUser $user) {
$this->user = $user;
return $this;
}
public function getUser() {
return $this->user;
}
public function setLabel($val) {
$this->label = $val;
return $this;
}
public function getLabel() {
return $this->label;
}
public function setAuxiliaryKey($val) {
$this->auxiliaryKey = $val;
return $this;
}
public function getAuxiliaryKey() {
return $this->auxiliaryKey;
}
public function setCaption($val) {
$this->caption = $val;
return $this;
}
public function getCaption() {
return $this->caption;
}
public function setValue($val) {
$this->value = $val;
return $this;
}
public function getValue() {
return $this->value;
}
public function validate() {
return true;
}
public function isRequired() {
return false;
}
public function setType($val) {
$this->type = $val;
return $this;
}
public function getType() {
return $this->type;
}
public function renderControl() {
return null;
}
public function renderForDetailView() {
return $this->getValue();
}
/**
* When the user creates a task, the UI prompts them to "Create another
* similar task". This copies some fields (e.g., Owner and CCs) but not other
* fields (e.g., description). If this custom field should also be copied,
* return true from this method.
*
* @return bool True to copy the default value from the template task when
* creating a new similar task.
*/
public function shouldCopyWhenCreatingSimilarTask() {
return false;
}
/**
* Render a verb to appear in email titles when a transaction involving this
* field occurs. Specifically, Maniphest emails are formatted like this:
*
* [Maniphest] [Verb Here] TNNN: Task title here
* ^^^^^^^^^
*
* You should optionally return a title-case verb or short phrase like
* "Created", "Retitled", "Closed", "Resolved", "Commented On",
* "Lowered Priority", etc., which describes the transaction.
*
* @param ManiphestTransaction The transaction which needs description.
* @return string|null A short description of the transaction.
*/
public function renderTransactionEmailVerb(
ManiphestTransaction $transaction) {
return null;
}
/**
* Render a short description of the transaction, to appear above comments
* in the Maniphest transaction log. The string will be rendered after the
* acting user's name. Examples are:
*
* added a comment
* added alincoln to CC
* claimed this task
* created this task
* closed this task out of spite
*
* You should return a similar string, describing the transaction.
*
* Note the ##$target## parameter -- Maniphest needs to render transaction
* descriptions for different targets, like web and email. This method will
* be called with a ##ManiphestAuxiliaryFieldSpecification::RENDER_TARGET_*##
* constant describing the intended target.
*
* @param ManiphestTransaction The transaction which needs description.
* @param const Constant describing the rendering target (e.g., html or text).
* @return string|null Description of the transaction.
*/
public function renderTransactionDescription(
ManiphestTransaction $transaction,
$target) {
return 'updated a custom field';
}
+ public function getRequiredHandlePHIDs() {
+ return array();
+ }
+
+ public function setHandles(array $handles) {
+ assert_instances_of($handles, 'PhabricatorObjectHandle');
+ $this->handles = array_select_keys(
+ $handles,
+ $this->getRequiredHandlePHIDs());
+ return $this;
+ }
+
+ public function getHandle($phid) {
+ if (empty($this->handles[$phid])) {
+ throw new Exception(
+ "Field is requesting a handle ('{$phid}') it did not require.");
+ }
+ return $this->handles[$phid];
+ }
public function getMarkupFields() {
return array();
}
public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
$this->markupEngine = $engine;
return $this;
}
public function getMarkupEngine() {
return $this->markupEngine;
}
/* -( PhabricatorMarkupInterface )----------------------------------------- */
public function getMarkupFieldKey($field) {
$hash = PhabricatorHash::digestForIndex($this->getMarkupText($field));
return 'maux:'.$this->getAuxiliaryKey().':'.$hash;
}
public function newMarkupEngine($field) {
return PhabricatorMarkupEngine::newManiphestMarkupEngine();
}
public function getMarkupText($field) {
return $this->getValue();
}
public function didMarkupText(
$field,
$output,
PhutilMarkupEngine $engine) {
return phutil_tag(
'div',
array(
'class' => 'phabricator-remarkup',
),
$output);
}
public function shouldUseMarkupCache($field) {
return true;
}
}
diff --git a/src/applications/maniphest/controller/ManiphestTaskDetailController.php b/src/applications/maniphest/controller/ManiphestTaskDetailController.php
index 0533c75b47..d90643cc0c 100644
--- a/src/applications/maniphest/controller/ManiphestTaskDetailController.php
+++ b/src/applications/maniphest/controller/ManiphestTaskDetailController.php
@@ -1,535 +1,545 @@
id = $data['id'];
}
public function processRequest() {
$request = $this->getRequest();
$user = $request->getUser();
$e_title = null;
$priority_map = ManiphestTaskPriority::getTaskPriorityMap();
$task = id(new ManiphestTask())->load($this->id);
if (!$task) {
return new Aphront404Response();
}
$workflow = $request->getStr('workflow');
$parent_task = null;
if ($workflow && is_numeric($workflow)) {
$parent_task = id(new ManiphestTask())->load($workflow);
}
$transactions = id(new ManiphestTransaction())->loadAllWhere(
'taskID = %d ORDER BY id ASC',
$task->getID());
+ $extensions = ManiphestTaskExtensions::newExtensions();
+ $aux_fields = $extensions->loadFields($task, $user);
+
$e_commit = PhabricatorEdgeConfig::TYPE_TASK_HAS_COMMIT;
$e_dep_on = PhabricatorEdgeConfig::TYPE_TASK_DEPENDS_ON_TASK;
$e_dep_by = PhabricatorEdgeConfig::TYPE_TASK_DEPENDED_ON_BY_TASK;
$e_rev = PhabricatorEdgeConfig::TYPE_TASK_HAS_RELATED_DREV;
$phid = $task->getPHID();
$query = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array($phid))
->withEdgeTypes(
array(
$e_commit,
$e_dep_on,
$e_dep_by,
$e_rev,
));
$edges = idx($query->execute(), $phid);
$phids = array_fill_keys($query->getDestinationPHIDs(), true);
foreach ($transactions as $transaction) {
foreach ($transaction->extractPHIDs() as $phid) {
$phids[$phid] = true;
}
}
foreach ($task->getCCPHIDs() as $phid) {
$phids[$phid] = true;
}
foreach ($task->getProjectPHIDs() as $phid) {
$phids[$phid] = true;
}
if ($task->getOwnerPHID()) {
$phids[$task->getOwnerPHID()] = true;
}
$phids[$task->getAuthorPHID()] = true;
$attached = $task->getAttached();
foreach ($attached as $type => $list) {
foreach ($list as $phid => $info) {
$phids[$phid] = true;
}
}
if ($parent_task) {
$phids[$parent_task->getPHID()] = true;
}
$phids = array_keys($phids);
+
+ $phids = array_merge(
+ $phids,
+ array_mergev(mpull($aux_fields, 'getRequiredHandlePHIDs')));
+
$this->loadHandles($phids);
+ $handles = $this->getLoadedHandles();
+ foreach ($aux_fields as $aux_field) {
+ $aux_field->setHandles($handles);
+ }
+
$context_bar = null;
if ($parent_task) {
$context_bar = new AphrontContextBarView();
$context_bar->addButton(phutil_tag(
'a',
array(
'href' => '/maniphest/task/create/?parent='.$parent_task->getID(),
'class' => 'green button',
),
'Create Another Subtask'));
$context_bar->appendChild(hsprintf(
'Created a subtask of %s',
$this->getHandle($parent_task->getPHID())->renderLink()));
} else if ($workflow == 'create') {
$context_bar = new AphrontContextBarView();
$context_bar->addButton(phutil_tag('label', array(), 'Create Another'));
$context_bar->addButton(phutil_tag(
'a',
array(
'href' => '/maniphest/task/create/?template='.$task->getID(),
'class' => 'green button',
),
'Similar Task'));
$context_bar->addButton(phutil_tag(
'a',
array(
'href' => '/maniphest/task/create/',
'class' => 'green button',
),
'Empty Task'));
$context_bar->appendChild('New task created.');
}
$engine = new PhabricatorMarkupEngine();
$engine->setViewer($user);
$engine->addObject($task, ManiphestTask::MARKUP_FIELD_DESCRIPTION);
foreach ($transactions as $xaction) {
if ($xaction->hasComments()) {
$engine->addObject($xaction, ManiphestTransaction::MARKUP_FIELD_BODY);
}
}
- $extensions = ManiphestTaskExtensions::newExtensions();
- $aux_fields = $extensions->loadFields($task, $user);
-
foreach ($aux_fields as $aux_field) {
foreach ($aux_field->getMarkupFields() as $markup_field) {
$engine->addObject($aux_field, $markup_field);
$aux_field->setMarkupEngine($engine);
}
}
$engine->process();
$transaction_types = ManiphestTransactionType::getTransactionTypeMap();
$resolution_types = ManiphestTaskStatus::getTaskStatusMap();
if ($task->getStatus() == ManiphestTaskStatus::STATUS_OPEN) {
$resolution_types = array_select_keys(
$resolution_types,
array(
ManiphestTaskStatus::STATUS_CLOSED_RESOLVED,
ManiphestTaskStatus::STATUS_CLOSED_WONTFIX,
ManiphestTaskStatus::STATUS_CLOSED_INVALID,
ManiphestTaskStatus::STATUS_CLOSED_SPITE,
));
} else {
$resolution_types = array(
ManiphestTaskStatus::STATUS_OPEN => 'Reopened',
);
$transaction_types[ManiphestTransactionType::TYPE_STATUS] =
'Reopen Task';
unset($transaction_types[ManiphestTransactionType::TYPE_PRIORITY]);
unset($transaction_types[ManiphestTransactionType::TYPE_OWNER]);
}
$default_claim = array(
$user->getPHID() => $user->getUsername().' ('.$user->getRealName().')',
);
$draft = id(new PhabricatorDraft())->loadOneWhere(
'authorPHID = %s AND draftKey = %s',
$user->getPHID(),
$task->getPHID());
if ($draft) {
$draft_text = $draft->getDraft();
} else {
$draft_text = null;
}
$is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
if ($is_serious) {
// Prevent tasks from being closed "out of spite" in serious business
// installs.
unset($resolution_types[ManiphestTaskStatus::STATUS_CLOSED_SPITE]);
}
$comment_form = new AphrontFormView();
$comment_form
->setUser($user)
->setAction('/maniphest/transaction/save/')
->setEncType('multipart/form-data')
->addHiddenInput('taskID', $task->getID())
->appendChild(
id(new AphrontFormSelectControl())
->setLabel('Action')
->setName('action')
->setOptions($transaction_types)
->setID('transaction-action'))
->appendChild(
id(new AphrontFormSelectControl())
->setLabel('Resolution')
->setName('resolution')
->setControlID('resolution')
->setControlStyle('display: none')
->setOptions($resolution_types))
->appendChild(
id(new AphrontFormTokenizerControl())
->setLabel('Assign To')
->setName('assign_to')
->setControlID('assign_to')
->setControlStyle('display: none')
->setID('assign-tokenizer')
->setDisableBehavior(true))
->appendChild(
id(new AphrontFormTokenizerControl())
->setLabel('CCs')
->setName('ccs')
->setControlID('ccs')
->setControlStyle('display: none')
->setID('cc-tokenizer')
->setDisableBehavior(true))
->appendChild(
id(new AphrontFormSelectControl())
->setLabel('Priority')
->setName('priority')
->setOptions($priority_map)
->setControlID('priority')
->setControlStyle('display: none')
->setValue($task->getPriority()))
->appendChild(
id(new AphrontFormTokenizerControl())
->setLabel('Projects')
->setName('projects')
->setControlID('projects')
->setControlStyle('display: none')
->setID('projects-tokenizer')
->setDisableBehavior(true))
->appendChild(
id(new AphrontFormFileControl())
->setLabel('File')
->setName('file')
->setControlID('file')
->setControlStyle('display: none'))
->appendChild(
id(new PhabricatorRemarkupControl())
->setLabel('Comments')
->setName('comments')
->setValue($draft_text)
->setID('transaction-comments')
->setUser($user))
->appendChild(
id(new AphrontFormDragAndDropUploadControl())
->setLabel('Attached Files')
->setName('files')
->setActivatedClass('aphront-panel-view-drag-and-drop'))
->appendChild(
id(new AphrontFormSubmitControl())
->setValue($is_serious ? 'Submit' : 'Avast!'));
$control_map = array(
ManiphestTransactionType::TYPE_STATUS => 'resolution',
ManiphestTransactionType::TYPE_OWNER => 'assign_to',
ManiphestTransactionType::TYPE_CCS => 'ccs',
ManiphestTransactionType::TYPE_PRIORITY => 'priority',
ManiphestTransactionType::TYPE_PROJECTS => 'projects',
ManiphestTransactionType::TYPE_ATTACH => 'file',
);
$tokenizer_map = array(
ManiphestTransactionType::TYPE_PROJECTS => array(
'id' => 'projects-tokenizer',
'src' => '/typeahead/common/projects/',
'ondemand' => PhabricatorEnv::getEnvConfig('tokenizer.ondemand'),
'placeholder' => 'Type a project name...',
),
ManiphestTransactionType::TYPE_OWNER => array(
'id' => 'assign-tokenizer',
'src' => '/typeahead/common/users/',
'value' => $default_claim,
'limit' => 1,
'ondemand' => PhabricatorEnv::getEnvConfig('tokenizer.ondemand'),
'placeholder' => 'Type a user name...',
),
ManiphestTransactionType::TYPE_CCS => array(
'id' => 'cc-tokenizer',
'src' => '/typeahead/common/mailable/',
'ondemand' => PhabricatorEnv::getEnvConfig('tokenizer.ondemand'),
'placeholder' => 'Type a user or mailing list...',
),
);
Javelin::initBehavior('maniphest-transaction-controls', array(
'select' => 'transaction-action',
'controlMap' => $control_map,
'tokenizers' => $tokenizer_map,
));
Javelin::initBehavior('maniphest-transaction-preview', array(
'uri' => '/maniphest/transaction/preview/'.$task->getID().'/',
'preview' => 'transaction-preview',
'comments' => 'transaction-comments',
'action' => 'transaction-action',
'map' => $control_map,
'tokenizers' => $tokenizer_map,
));
$comment_header = id(new PhabricatorHeaderView())
->setHeader($is_serious ? pht('Add Comment') : pht('Weigh In'));
$preview_panel = hsprintf(
'
',
pht('Loading preview...'));
$transaction_view = new ManiphestTransactionListView();
$transaction_view->setTransactions($transactions);
$transaction_view->setHandles($this->getLoadedHandles());
$transaction_view->setUser($user);
$transaction_view->setAuxiliaryFields($aux_fields);
$transaction_view->setMarkupEngine($engine);
PhabricatorFeedStoryNotification::updateObjectNotificationViews(
$user, $task->getPHID());
$object_name = 'T'.$task->getID();
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addCrumb(
id(new PhabricatorCrumbView())
->setName($object_name)
->setHref('/'.$object_name));
$header = $this->buildHeaderView($task);
$actions = $this->buildActionView($task);
$properties = $this->buildPropertyView($task, $aux_fields, $edges, $engine);
return $this->buildApplicationPage(
array(
$crumbs,
$context_bar,
$header,
$actions,
$properties,
$transaction_view,
$comment_header,
$comment_form,
$preview_panel,
),
array(
'title' => 'T'.$task->getID().' '.$task->getTitle(),
'pageObjects' => array($task->getPHID()),
'device' => true,
));
}
private function buildHeaderView(ManiphestTask $task) {
$view = id(new PhabricatorHeaderView())
->setHeader($task->getTitle());
$status = $task->getStatus();
$status_name = ManiphestTaskStatus::getTaskStatusFullName($status);
$status_color = ManiphestTaskStatus::getTaskStatusTagColor($status);
$view->addTag(
id(new PhabricatorTagView())
->setType(PhabricatorTagView::TYPE_STATE)
->setName($status_name)
->setBackgroundColor($status_color));
return $view;
}
private function buildActionView(ManiphestTask $task) {
$viewer = $this->getRequest()->getUser();
$id = $task->getID();
$phid = $task->getPHID();
$view = new PhabricatorActionListView();
$view->setUser($viewer);
$view->setObject($task);
$view->addAction(
id(new PhabricatorActionView())
->setName(pht('Edit Task'))
->setIcon('edit')
->setHref($this->getApplicationURI("/task/edit/{$id}/")));
$view->addAction(
id(new PhabricatorActionView())
->setName(pht('Merge Duplicates'))
->setHref("/search/attach/{$phid}/TASK/merge/")
->setWorkflow(true)
->setWorkflow(true)
->setIcon('merge'));
$view->addAction(
id(new PhabricatorActionView())
->setName(pht('Create Subtask'))
->setHref($this->getApplicationURI("/task/create/?parent={$id}"))
->setIcon('fork'));
$view->addAction(
id(new PhabricatorActionView())
->setName(pht('Edit Dependencies'))
->setHref("/search/attach/{$phid}/TASK/dependencies/")
->setWorkflow(true)
->setIcon('link'));
$view->addAction(
id(new PhabricatorActionView())
->setName(pht('Edit Differential Revisions'))
->setHref("/search/attach/{$phid}/DREV/")
->setWorkflow(true)
->setIcon('attach'));
return $view;
}
private function buildPropertyView(
ManiphestTask $task,
array $aux_fields,
array $edges,
PhabricatorMarkupEngine $engine) {
$viewer = $this->getRequest()->getUser();
$view = id(new PhabricatorPropertyListView())
->setUser($viewer)
->setObject($task);
$view->addProperty(
pht('Assigned To'),
$task->getOwnerPHID()
? $this->getHandle($task->getOwnerPHID())->renderLink()
: phutil_tag('em', array(), pht('None')));
$view->addProperty(
pht('Priority'),
ManiphestTaskPriority::getTaskPriorityName($task->getPriority()));
$view->addProperty(
pht('Subscribers'),
$task->getCCPHIDs()
? $this->renderHandlesForPHIDs($task->getCCPHIDs(), ',')
: phutil_tag('em', array(), pht('None')));
$view->addProperty(
pht('Author'),
$this->getHandle($task->getAuthorPHID())->renderLink());
$source = $task->getOriginalEmailSource();
if ($source) {
$subject = '[T'.$task->getID().'] '.$task->getTitle();
$view->addProperty(
pht('From Email'),
phutil_tag(
'a',
array(
'href' => 'mailto:'.$source.'?subject='.$subject
),
$source));
}
$view->addProperty(
pht('Projects'),
$task->getProjectPHIDs()
? $this->renderHandlesForPHIDs($task->getProjectPHIDs(), ',')
: phutil_tag('em', array(), pht('None')));
foreach ($aux_fields as $aux_field) {
$value = $aux_field->renderForDetailView();
if (strlen($value)) {
$view->addProperty($aux_field->getLabel(), $value);
}
}
$edge_types = array(
PhabricatorEdgeConfig::TYPE_TASK_DEPENDED_ON_BY_TASK
=> pht('Dependent Tasks'),
PhabricatorEdgeConfig::TYPE_TASK_DEPENDS_ON_TASK
=> pht('Depends On'),
PhabricatorEdgeConfig::TYPE_TASK_HAS_RELATED_DREV
=> pht('Differential Revisions'),
PhabricatorEdgeConfig::TYPE_TASK_HAS_COMMIT
=> pht('Commits'),
);
foreach ($edge_types as $edge_type => $edge_name) {
if ($edges[$edge_type]) {
$view->addProperty(
$edge_name,
$this->renderHandlesForPHIDs(array_keys($edges[$edge_type])));
}
}
$attached = $task->getAttached();
$file_infos = idx($attached, PhabricatorPHIDConstants::PHID_TYPE_FILE);
if ($file_infos) {
$file_phids = array_keys($file_infos);
$files = id(new PhabricatorFile())->loadAllWhere(
'phid IN (%Ls)',
$file_phids);
$file_view = new PhabricatorFileLinkListView();
$file_view->setFiles($files);
$view->addProperty(
pht('Files'),
$file_view->render());
}
$view->invokeWillRenderEvent();
if (strlen($task->getDescription())) {
$view->addSectionHeader(pht('Description'));
$view->addTextContent(
phutil_tag(
'div',
array(
'class' => 'phabricator-remarkup',
),
$engine->getOutput($task, ManiphestTask::MARKUP_FIELD_DESCRIPTION)));
}
return $view;
}
}
diff --git a/src/applications/maniphest/controller/ManiphestTaskEditController.php b/src/applications/maniphest/controller/ManiphestTaskEditController.php
index ed96eb7453..10e1e1a20b 100644
--- a/src/applications/maniphest/controller/ManiphestTaskEditController.php
+++ b/src/applications/maniphest/controller/ManiphestTaskEditController.php
@@ -1,542 +1,543 @@
id = idx($data, 'id');
}
public function processRequest() {
$request = $this->getRequest();
$user = $request->getUser();
$files = array();
$parent_task = null;
$template_id = null;
if ($this->id) {
$task = id(new ManiphestTask())->load($this->id);
if (!$task) {
return new Aphront404Response();
}
} else {
$task = new ManiphestTask();
$task->setPriority(ManiphestTaskPriority::getDefaultPriority());
$task->setAuthorPHID($user->getPHID());
// These allow task creation with defaults.
if (!$request->isFormPost()) {
$task->setTitle($request->getStr('title'));
$default_projects = $request->getStr('projects');
if ($default_projects) {
$task->setProjectPHIDs(explode(';', $default_projects));
}
}
$file_phids = $request->getArr('files', array());
if (!$file_phids) {
// Allow a single 'file' key instead, mostly since Mac OS X urlencodes
// square brackets in URLs when passed to 'open', so you can't 'open'
// a URL like '?files[]=xyz' and have PHP interpret it correctly.
$phid = $request->getStr('file');
if ($phid) {
$file_phids = array($phid);
}
}
if ($file_phids) {
$files = id(new PhabricatorFile())->loadAllWhere(
'phid IN (%Ls)',
$file_phids);
}
$template_id = $request->getInt('template');
// You can only have a parent task if you're creating a new task.
$parent_id = $request->getInt('parent');
if ($parent_id) {
$parent_task = id(new ManiphestTask())->load($parent_id);
}
}
$errors = array();
$e_title = true;
$extensions = ManiphestTaskExtensions::newExtensions();
$aux_fields = $extensions->loadFields($task, $user);
if ($request->isFormPost()) {
$changes = array();
$new_title = $request->getStr('title');
$new_desc = $request->getStr('description');
$new_status = $request->getStr('status');
$workflow = '';
if ($task->getID()) {
if ($new_title != $task->getTitle()) {
$changes[ManiphestTransactionType::TYPE_TITLE] = $new_title;
}
if ($new_desc != $task->getDescription()) {
$changes[ManiphestTransactionType::TYPE_DESCRIPTION] = $new_desc;
}
if ($new_status != $task->getStatus()) {
$changes[ManiphestTransactionType::TYPE_STATUS] = $new_status;
}
} else {
$task->setTitle($new_title);
$task->setDescription($new_desc);
$changes[ManiphestTransactionType::TYPE_STATUS] =
ManiphestTaskStatus::STATUS_OPEN;
$workflow = 'create';
}
$owner_tokenizer = $request->getArr('assigned_to');
$owner_phid = reset($owner_tokenizer);
if (!strlen($new_title)) {
$e_title = pht('Required');
$errors[] = pht('Title is required.');
}
foreach ($aux_fields as $aux_field) {
$aux_field->setValueFromRequest($request);
- if ($aux_field->isRequired() && !strlen($aux_field->getValue())) {
+ if ($aux_field->isRequired() && !$aux_field->getValue()) {
$errors[] = $aux_field->getLabel() . ' is required.';
$aux_field->setError('Required');
}
- if (strlen($aux_field->getValue())) {
- try {
- $aux_field->validate();
- } catch (Exception $e) {
- $errors[] = $e->getMessage();
- $aux_field->setError('Invalid');
- }
+ try {
+ $aux_field->validate();
+ } catch (Exception $e) {
+ $errors[] = $e->getMessage();
+ $aux_field->setError('Invalid');
}
}
if ($errors) {
$task->setPriority($request->getInt('priority'));
$task->setOwnerPHID($owner_phid);
$task->setCCPHIDs($request->getArr('cc'));
$task->setProjectPHIDs($request->getArr('projects'));
} else {
if ($request->getInt('priority') != $task->getPriority()) {
$changes[ManiphestTransactionType::TYPE_PRIORITY] =
$request->getInt('priority');
}
if ($owner_phid != $task->getOwnerPHID()) {
$changes[ManiphestTransactionType::TYPE_OWNER] = $owner_phid;
}
if ($request->getArr('cc') != $task->getCCPHIDs()) {
$changes[ManiphestTransactionType::TYPE_CCS] = $request->getArr('cc');
}
$new_proj_arr = $request->getArr('projects');
$new_proj_arr = array_values($new_proj_arr);
sort($new_proj_arr);
$cur_proj_arr = $task->getProjectPHIDs();
$cur_proj_arr = array_values($cur_proj_arr);
sort($cur_proj_arr);
if ($new_proj_arr != $cur_proj_arr) {
$changes[ManiphestTransactionType::TYPE_PROJECTS] = $new_proj_arr;
}
if ($files) {
$file_map = mpull($files, 'getPHID');
$file_map = array_fill_keys($file_map, array());
$changes[ManiphestTransactionType::TYPE_ATTACH] = array(
PhabricatorPHIDConstants::PHID_TYPE_FILE => $file_map,
);
}
$content_source = PhabricatorContentSource::newForSource(
PhabricatorContentSource::SOURCE_WEB,
array(
'ip' => $request->getRemoteAddr(),
));
$template = new ManiphestTransaction();
$template->setAuthorPHID($user->getPHID());
$template->setContentSource($content_source);
$transactions = array();
foreach ($changes as $type => $value) {
$transaction = clone $template;
$transaction->setTransactionType($type);
$transaction->setNewValue($value);
$transactions[] = $transaction;
}
if ($aux_fields) {
foreach ($aux_fields as $aux_field) {
$transaction = clone $template;
$transaction->setTransactionType(
ManiphestTransactionType::TYPE_AUXILIARY);
$aux_key = $aux_field->getAuxiliaryKey();
$transaction->setMetadataValue('aux:key', $aux_key);
$transaction->setNewValue($aux_field->getValueForStorage());
$transactions[] = $transaction;
}
}
if ($transactions) {
$is_new = !$task->getID();
$event = new PhabricatorEvent(
PhabricatorEventType::TYPE_MANIPHEST_WILLEDITTASK,
array(
'task' => $task,
'new' => $is_new,
'transactions' => $transactions,
));
$event->setUser($user);
$event->setAphrontRequest($request);
PhutilEventEngine::dispatchEvent($event);
$task = $event->getValue('task');
$transactions = $event->getValue('transactions');
$editor = new ManiphestTransactionEditor();
$editor->setActor($user);
$editor->setAuxiliaryFields($aux_fields);
$editor->applyTransactions($task, $transactions);
$event = new PhabricatorEvent(
PhabricatorEventType::TYPE_MANIPHEST_DIDEDITTASK,
array(
'task' => $task,
'new' => $is_new,
'transactions' => $transactions,
));
$event->setUser($user);
$event->setAphrontRequest($request);
PhutilEventEngine::dispatchEvent($event);
}
if ($parent_task) {
id(new PhabricatorEdgeEditor())
->setActor($user)
->addEdge(
$parent_task->getPHID(),
PhabricatorEdgeConfig::TYPE_TASK_DEPENDS_ON_TASK,
$task->getPHID())
->save();
$workflow = $parent_task->getID();
}
$redirect_uri = '/T'.$task->getID();
if ($workflow) {
$redirect_uri .= '?workflow='.$workflow;
}
return id(new AphrontRedirectResponse())
->setURI($redirect_uri);
}
} else {
if (!$task->getID()) {
$task->setCCPHIDs(array(
$user->getPHID(),
));
if ($template_id) {
$template_task = id(new ManiphestTask())->load($template_id);
if ($template_task) {
$task->setCCPHIDs($template_task->getCCPHIDs());
$task->setProjectPHIDs($template_task->getProjectPHIDs());
$task->setOwnerPHID($template_task->getOwnerPHID());
$task->setPriority($template_task->getPriority());
if ($aux_fields) {
$template_task->loadAndAttachAuxiliaryAttributes();
foreach ($aux_fields as $aux_field) {
if (!$aux_field->shouldCopyWhenCreatingSimilarTask()) {
continue;
}
$aux_key = $aux_field->getAuxiliaryKey();
$value = $template_task->getAuxiliaryAttribute($aux_key);
$aux_field->setValueFromStorage($value);
}
}
}
}
}
}
$phids = array_merge(
array($task->getOwnerPHID()),
$task->getCCPHIDs(),
- $task->getProjectPHIDs());
+ $task->getProjectPHIDs(),
+ array_mergev(mpull($aux_fields, 'getRequiredHandlePHIDs')));
if ($parent_task) {
$phids[] = $parent_task->getPHID();
}
$phids = array_filter($phids);
$phids = array_unique($phids);
$handles = $this->loadViewerHandles($phids);
+ foreach ($aux_fields as $aux_field) {
+ $aux_field->setHandles($handles);
+ }
+
$tvalues = mpull($handles, 'getFullName', 'getPHID');
$error_view = null;
if ($errors) {
$error_view = new AphrontErrorView();
$error_view->setErrors($errors);
$error_view->setTitle(pht('Form Errors'));
}
$priority_map = ManiphestTaskPriority::getTaskPriorityMap();
if ($task->getOwnerPHID()) {
$assigned_value = array(
$task->getOwnerPHID() => $handles[$task->getOwnerPHID()]->getFullName(),
);
} else {
$assigned_value = array();
}
if ($task->getCCPHIDs()) {
$cc_value = array_select_keys($tvalues, $task->getCCPHIDs());
} else {
$cc_value = array();
}
if ($task->getProjectPHIDs()) {
$projects_value = array_select_keys($tvalues, $task->getProjectPHIDs());
} else {
$projects_value = array();
}
$cancel_id = nonempty($task->getID(), $template_id);
if ($cancel_id) {
$cancel_uri = '/T'.$cancel_id;
} else {
$cancel_uri = '/maniphest/';
}
if ($task->getID()) {
$button_name = pht('Save Task');
$header_name = pht('Edit Task');
} else if ($parent_task) {
$cancel_uri = '/T'.$parent_task->getID();
$button_name = pht('Create Task');
$header_name = pht('Create New Subtask');
} else {
$button_name = pht('Create Task');
$header_name = pht('Create New Task');
}
require_celerity_resource('maniphest-task-edit-css');
$project_tokenizer_id = celerity_generate_unique_node_id();
$form = new AphrontFormView();
$form
->setUser($user)
->setAction($request->getRequestURI()->getPath())
->addHiddenInput('template', $template_id);
if ($parent_task) {
$form
->appendChild(
id(new AphrontFormStaticControl())
->setLabel(pht('Parent Task'))
->setValue($handles[$parent_task->getPHID()]->getFullName()))
->addHiddenInput('parent', $parent_task->getID());
}
$form
->appendChild(
id(new AphrontFormTextAreaControl())
->setLabel(pht('Title'))
->setName('title')
->setError($e_title)
->setHeight(AphrontFormTextAreaControl::HEIGHT_VERY_SHORT)
->setValue($task->getTitle()));
if ($task->getID()) {
// Only show this in "edit" mode, not "create" mode, since creating a
// non-open task is kind of silly and it would just clutter up the
// "create" interface.
$form
->appendChild(
id(new AphrontFormSelectControl())
->setLabel(pht('Status'))
->setName('status')
->setValue($task->getStatus())
->setOptions(ManiphestTaskStatus::getTaskStatusMap()));
}
$form
->appendChild(
id(new AphrontFormTokenizerControl())
->setLabel(pht('Assigned To'))
->setName('assigned_to')
->setValue($assigned_value)
->setUser($user)
->setDatasource('/typeahead/common/users/')
->setLimit(1))
->appendChild(
id(new AphrontFormTokenizerControl())
->setLabel(pht('CC'))
->setName('cc')
->setValue($cc_value)
->setUser($user)
->setDatasource('/typeahead/common/mailable/'))
->appendChild(
id(new AphrontFormSelectControl())
->setLabel(pht('Priority'))
->setName('priority')
->setOptions($priority_map)
->setValue($task->getPriority()))
->appendChild(
id(new AphrontFormTokenizerControl())
->setLabel(pht('Projects'))
->setName('projects')
->setValue($projects_value)
->setID($project_tokenizer_id)
->setCaption(
javelin_tag(
'a',
array(
'href' => '/project/create/',
'mustcapture' => true,
'sigil' => 'project-create',
),
pht('Create New Project')))
->setDatasource('/typeahead/common/projects/'));
- if ($aux_fields) {
- foreach ($aux_fields as $aux_field) {
- if ($aux_field->isRequired() &&
- !$aux_field->getError() &&
- !$aux_field->getValue()) {
- $aux_field->setError(true);
- }
-
- $aux_control = $aux_field->renderControl();
- $form->appendChild($aux_control);
+ foreach ($aux_fields as $aux_field) {
+ if ($aux_field->isRequired() &&
+ !$aux_field->getError() &&
+ !$aux_field->getValue()) {
+ $aux_field->setError(true);
}
+
+ $aux_control = $aux_field->renderControl();
+ $form->appendChild($aux_control);
}
require_celerity_resource('aphront-error-view-css');
Javelin::initBehavior('project-create', array(
'tokenizerID' => $project_tokenizer_id,
));
if ($files) {
$file_display = mpull($files, 'getName');
$file_display = phutil_implode_html(phutil_tag('br'), $file_display);
$form->appendChild(
id(new AphrontFormMarkupControl())
->setLabel(pht('Files'))
->setValue($file_display));
foreach ($files as $ii => $file) {
$form->addHiddenInput('files['.$ii.']', $file->getPHID());
}
}
$description_control = new PhabricatorRemarkupControl();
// "Upsell" creating tasks via email in create flows if the instance is
// configured for this awesomeness.
$email_create = PhabricatorEnv::getEnvConfig(
'metamta.maniphest.public-create-email');
if (!$task->getID() && $email_create) {
$email_hint = pht(
'You can also create tasks by sending an email to: %s',
phutil_tag('tt', array(), $email_create));
$description_control->setCaption($email_hint);
}
$description_control
->setLabel(pht('Description'))
->setName('description')
->setID('description-textarea')
->setValue($task->getDescription())
->setUser($user);
$form
->appendChild($description_control);
if (!$task->getID()) {
$form
->appendChild(
id(new AphrontFormDragAndDropUploadControl())
->setLabel(pht('Attached Files'))
->setName('files')
->setActivatedClass('aphront-panel-view-drag-and-drop'));
}
$form
->appendChild(
id(new AphrontFormSubmitControl())
->addCancelButton($cancel_uri)
->setValue($button_name));
$panel = new AphrontPanelView();
$panel->setWidth(AphrontPanelView::WIDTH_FULL);
$panel->setHeader($header_name);
$panel->appendChild($form);
$panel->setNoBackground();
$description_preview_panel = hsprintf(
'');
Javelin::initBehavior(
'maniphest-description-preview',
array(
'preview' => 'description-preview',
'textarea' => 'description-textarea',
'uri' => '/maniphest/task/descriptionpreview/',
));
if ($task->getID()) {
$page_objects = array( $task->getPHID() );
} else {
$page_objects = array();
}
return $this->buildApplicationPage(
array(
$error_view,
$panel,
$description_preview_panel,
),
array(
'title' => $header_name,
'pageObjects' => $page_objects,
'device' => true
));
}
}
diff --git a/src/docs/userguide/maniphest_custom.diviner b/src/docs/userguide/maniphest_custom.diviner
index 45c079aaac..47dd7b32ab 100644
--- a/src/docs/userguide/maniphest_custom.diviner
+++ b/src/docs/userguide/maniphest_custom.diviner
@@ -1,77 +1,79 @@
@title Maniphest User Guide: Adding Custom Fields
@group userguide
How to add custom fields to Maniphest.
= Overview =
Maniphest provides some support for adding new fields to tasks, like an
"cost" field, a "milestone" field, etc.
NOTE: Currently, these fields are somewhat limited. They primarily give you a
structured way to record data on tasks, but there isn't much support for
bringing them into other interfaces (e.g., querying by them, aggregating them,
drawing graphs, etc.). If you have a use case, let us know what you want to do
and maybe we can figure something out. This data is also exposed via the Conduit
API, so you might be able to write your own interface if you want to do
something very custom.
= Simple Field Customization =
If you don't need complicated display controls or sophisticated validation, you
can add simple fields. These allow you to attach things like strings, numbers,
and dropdown menus to the task template.
Customize Maniphest fields by setting ##maniphest.custom-fields## in your
configuration. For example, suppose you want to add "Estimated Hours" and
"Actual Hours" fields. To do this, set your configuration like this:
'maniphest.custom-fields' => array(
'mycompany:estimated-hours' => array(
'label' => 'Estimated Hours',
'type' => 'int',
'caption' => 'Estimated number of hours this will take.',
'required' => false,
),
'mycompany:actual-hours' => array(
'label' => 'Actual Hours',
'type' => 'int',
'required' => false,
),
)
Each array key must be unique, and is used to organize the internal storage of
the field. These options are available:
- **label**: Display label for the field on the edit and detail interfaces.
- - **type**: Field type, one of **int**, **string**, **bool**, **select**,
- **remarkup**, or **date**.
+ - **type**: Field type: one of **int**, **string**, **bool**, **select**,
+ **remarkup**, **user**, **users**, or **date**.
- **caption**: A caption to display underneath the field (optional).
- **required**: True if the user should be required to provide a value.
- **options**: If type is set to **select**, provide options for the dropdown
as a dictionary.
- **checkbox-label**: If type is set to **bool**, an optional string to
show next to the checkbox.
- **checkbox-value**: If type is set to **bool**, the value to show on
the detail view when the checkbox is selected.
- - **default**: Default field value. For **date**, you can use a string like
- `"July 4, 1990"`, `"5PM today"`, or any other valid input to `strtotime()`.
+ - **default**: Default field value.
+ - For **date**, you can use a string like `"July 4, 1990"`, `"5PM today"`,
+ or any other valid input to `strtotime()`.
+ - For **user** and **users**, you can use an array of user PHIDs.
- **copy**: When a user creates a task, the UI gives them an option to
"Create Another Similar Task". Some fields from the original task are copied
into the new task, while others are not; by default, fields are not copied.
If you want this field to be copied, specify `true` for the `copy` property.
= Advanced Field Customization =
If you want to add fields with more specialized validation, storage, or
rendering logic, you can do so with a little work:
- Extend @{class:ManiphestAuxiliaryFieldSpecification} and implement
your specialized rendering, validation, storage, etc., logic.
- Extend @{class:ManiphestTaskExtensions} and return a list of fields which
includes your custom field objects.
- Set `maniphest.custom-extensions` to the name of your new extensions
class.
This is relatively advanced but should give you significant flexibility in
defining custom fields.