diff --git a/src/applications/herald/controller/HeraldRuleController.php b/src/applications/herald/controller/HeraldRuleController.php index c16a45b47e..6780693031 100644 --- a/src/applications/herald/controller/HeraldRuleController.php +++ b/src/applications/herald/controller/HeraldRuleController.php @@ -1,672 +1,675 @@ id = (int)idx($data, 'id'); } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); $content_type_map = HeraldAdapter::getEnabledAdapterMap($user); $rule_type_map = HeraldRuleTypeConfig::getRuleTypeMap(); if ($this->id) { $id = $this->id; $rule = id(new HeraldRuleQuery()) ->setViewer($user) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$rule) { return new Aphront404Response(); } $cancel_uri = $this->getApplicationURI("rule/{$id}/"); } else { $rule = new HeraldRule(); $rule->setAuthorPHID($user->getPHID()); $rule->setMustMatchAll(1); $content_type = $request->getStr('content_type'); $rule->setContentType($content_type); $rule_type = $request->getStr('rule_type'); if (!isset($rule_type_map[$rule_type])) { $rule_type = HeraldRuleTypeConfig::RULE_TYPE_PERSONAL; } $rule->setRuleType($rule_type); $adapter = HeraldAdapter::getAdapterForContentType( $rule->getContentType()); if (!$adapter->supportsRuleType($rule->getRuleType())) { throw new Exception( pht( "This rule's content type does not support the selected rule ". "type.")); } if ($rule->isObjectRule()) { $rule->setTriggerObjectPHID($request->getStr('targetPHID')); $object = id(new PhabricatorObjectQuery()) ->setViewer($user) ->withPHIDs(array($rule->getTriggerObjectPHID())) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$object) { throw new Exception( pht('No valid object provided for object rule!')); } if (!$adapter->canTriggerOnObject($object)) { throw new Exception( pht('Object is of wrong type for adapter!')); } } $cancel_uri = $this->getApplicationURI(); } if ($rule->isGlobalRule()) { $this->requireApplicationCapability( HeraldManageGlobalRulesCapability::CAPABILITY); } $adapter = HeraldAdapter::getAdapterForContentType($rule->getContentType()); $local_version = id(new HeraldRule())->getConfigVersion(); if ($rule->getConfigVersion() > $local_version) { throw new Exception( pht( 'This rule was created with a newer version of Herald. You can not '. 'view or edit it in this older version. Upgrade your Phabricator '. 'deployment.')); } // Upgrade rule version to our version, since we might add newly-defined // conditions, etc. $rule->setConfigVersion($local_version); $rule_conditions = $rule->loadConditions(); $rule_actions = $rule->loadActions(); $rule->attachConditions($rule_conditions); $rule->attachActions($rule_actions); $e_name = true; $errors = array(); if ($request->isFormPost() && $request->getStr('save')) { list($e_name, $errors) = $this->saveRule($adapter, $rule, $request); if (!$errors) { $id = $rule->getID(); $uri = $this->getApplicationURI("rule/{$id}/"); return id(new AphrontRedirectResponse())->setURI($uri); } } $must_match_selector = $this->renderMustMatchSelector($rule); $repetition_selector = $this->renderRepetitionSelector($rule, $adapter); $handles = $this->loadHandlesForRule($rule); require_celerity_resource('herald-css'); $content_type_name = $content_type_map[$rule->getContentType()]; $rule_type_name = $rule_type_map[$rule->getRuleType()]; $form = id(new AphrontFormView()) ->setUser($user) ->setID('herald-rule-edit-form') ->addHiddenInput('content_type', $rule->getContentType()) ->addHiddenInput('rule_type', $rule->getRuleType()) ->addHiddenInput('save', 1) ->appendChild( // Build this explicitly (instead of using addHiddenInput()) // so we can add a sigil to it. javelin_tag( 'input', array( 'type' => 'hidden', 'name' => 'rule', 'sigil' => 'rule', ))) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Rule Name')) ->setName('name') ->setError($e_name) ->setValue($rule->getName())); $trigger_object_control = false; if ($rule->isObjectRule()) { $trigger_object_control = id(new AphrontFormStaticControl()) ->setValue( pht( 'This rule triggers for %s.', $handles[$rule->getTriggerObjectPHID()]->renderLink())); } $form ->appendChild( id(new AphrontFormMarkupControl()) ->setValue(pht( 'This %s rule triggers for %s.', phutil_tag('strong', array(), $rule_type_name), phutil_tag('strong', array(), $content_type_name)))) ->appendChild($trigger_object_control) ->appendChild( id(new PHUIFormInsetView()) ->setTitle(pht('Conditions')) ->setRightButton(javelin_tag( 'a', array( 'href' => '#', 'class' => 'button green', 'sigil' => 'create-condition', 'mustcapture' => true, ), pht('New Condition'))) ->setDescription( pht('When %s these conditions are met:', $must_match_selector)) ->setContent(javelin_tag( 'table', array( 'sigil' => 'rule-conditions', 'class' => 'herald-condition-table', ), ''))) ->appendChild( id(new PHUIFormInsetView()) ->setTitle(pht('Action')) ->setRightButton(javelin_tag( 'a', array( 'href' => '#', 'class' => 'button green', 'sigil' => 'create-action', 'mustcapture' => true, ), pht('New Action'))) ->setDescription(pht( 'Take these actions %s this rule matches:', $repetition_selector)) ->setContent(javelin_tag( 'table', array( 'sigil' => 'rule-actions', 'class' => 'herald-action-table', ), ''))) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Save Rule')) ->addCancelButton($cancel_uri)); $this->setupEditorBehavior($rule, $handles, $adapter); $title = $rule->getID() ? pht('Edit Herald Rule') : pht('Create Herald Rule'); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setFormErrors($errors) ->setForm($form); $crumbs = $this ->buildApplicationCrumbs() ->addTextCrumb($title); return $this->buildApplicationPage( array( $crumbs, $form_box, ), array( 'title' => pht('Edit Rule'), )); } private function saveRule(HeraldAdapter $adapter, $rule, $request) { $rule->setName($request->getStr('name')); $match_all = ($request->getStr('must_match') == 'all'); $rule->setMustMatchAll((int)$match_all); $repetition_policy_param = $request->getStr('repetition_policy'); $rule->setRepetitionPolicy( HeraldRepetitionPolicyConfig::toInt($repetition_policy_param)); $e_name = true; $errors = array(); if (!strlen($rule->getName())) { $e_name = pht('Required'); $errors[] = pht('Rule must have a name.'); } $data = json_decode($request->getStr('rule'), true); if (!is_array($data) || !$data['conditions'] || !$data['actions']) { throw new Exception('Failed to decode rule data.'); } $conditions = array(); foreach ($data['conditions'] as $condition) { if ($condition === null) { // We manage this as a sparse array on the client, so may receive // NULL if conditions have been removed. continue; } $obj = new HeraldCondition(); $obj->setFieldName($condition[0]); $obj->setFieldCondition($condition[1]); if (is_array($condition[2])) { $obj->setValue(array_keys($condition[2])); } else { $obj->setValue($condition[2]); } try { $adapter->willSaveCondition($obj); } catch (HeraldInvalidConditionException $ex) { $errors[] = $ex->getMessage(); } $conditions[] = $obj; } $actions = array(); foreach ($data['actions'] as $action) { if ($action === null) { // Sparse on the client; removals can give us NULLs. continue; } if (!isset($action[1])) { // Legitimate for any action which doesn't need a target, like // "Do nothing". $action[1] = null; } $obj = new HeraldAction(); $obj->setAction($action[0]); $obj->setTarget($action[1]); try { $adapter->willSaveAction($rule, $obj); } catch (HeraldInvalidActionException $ex) { $errors[] = $ex; } $actions[] = $obj; } $rule->attachConditions($conditions); $rule->attachActions($actions); if (!$errors) { $edit_action = $rule->getID() ? 'edit' : 'create'; $rule->openTransaction(); $rule->save(); $rule->saveConditions($conditions); $rule->saveActions($actions); $rule->saveTransaction(); } return array($e_name, $errors); } private function setupEditorBehavior( HeraldRule $rule, array $handles, HeraldAdapter $adapter) { $serial_conditions = array( array('default', 'default', ''), ); if ($rule->getConditions()) { $serial_conditions = array(); foreach ($rule->getConditions() as $condition) { $value = $condition->getValue(); switch ($condition->getFieldName()) { case HeraldAdapter::FIELD_TASK_PRIORITY: $value_map = array(); $priority_map = ManiphestTaskPriority::getTaskPriorityMap(); foreach ($value as $priority) { $value_map[$priority] = idx($priority_map, $priority); } $value = $value_map; break; case HeraldAdapter::FIELD_TASK_STATUS: $value_map = array(); $status_map = ManiphestTaskStatus::getTaskStatusMap(); foreach ($value as $status) { $value_map[$status] = idx($status_map, $status); } $value = $value_map; break; default: if (is_array($value)) { $value_map = array(); foreach ($value as $k => $fbid) { $value_map[$fbid] = $handles[$fbid]->getName(); } $value = $value_map; } break; } $serial_conditions[] = array( $condition->getFieldName(), $condition->getFieldCondition(), $value, ); } } $serial_actions = array( array('default', ''), ); if ($rule->getActions()) { $serial_actions = array(); foreach ($rule->getActions() as $action) { switch ($action->getAction()) { case HeraldAdapter::ACTION_FLAG: case HeraldAdapter::ACTION_BLOCK: $current_value = $action->getTarget(); break; default: if (is_array($action->getTarget())) { $target_map = array(); foreach ((array)$action->getTarget() as $fbid) { $target_map[$fbid] = $handles[$fbid]->getName(); } $current_value = $target_map; } else { $current_value = $action->getTarget(); } break; } $serial_actions[] = array( $action->getAction(), $current_value, ); } } $all_rules = $this->loadRulesThisRuleMayDependUpon($rule); $all_rules = mpull($all_rules, 'getName', 'getPHID'); asort($all_rules); $all_fields = $adapter->getFieldNameMap(); $all_conditions = $adapter->getConditionNameMap(); $all_actions = $adapter->getActionNameMap($rule->getRuleType()); $fields = $adapter->getFields(); $field_map = array_select_keys($all_fields, $fields); // Populate any fields which exist in the rule but which we don't know the // names of, so that saving a rule without touching anything doesn't change // it. foreach ($rule->getConditions() as $condition) { if (empty($field_map[$condition->getFieldName()])) { $field_map[$condition->getFieldName()] = pht(''); } } $actions = $adapter->getActions($rule->getRuleType()); $action_map = array_select_keys($all_actions, $actions); $config_info = array(); $config_info['fields'] = $field_map; $config_info['conditions'] = $all_conditions; $config_info['actions'] = $action_map; foreach ($config_info['fields'] as $field => $name) { $field_conditions = $adapter->getConditionsForField($field); $config_info['conditionMap'][$field] = $field_conditions; } foreach ($config_info['fields'] as $field => $fname) { foreach ($config_info['conditionMap'][$field] as $condition) { $value_type = $adapter->getValueTypeForFieldAndCondition( $field, $condition); $config_info['values'][$field][$condition] = $value_type; } } $config_info['rule_type'] = $rule->getRuleType(); foreach ($config_info['actions'] as $action => $name) { $config_info['targets'][$action] = $adapter->getValueTypeForAction( $action, $rule->getRuleType()); } $changeflag_options = PhabricatorRepositoryPushLog::getHeraldChangeFlagConditionOptions(); Javelin::initBehavior( 'herald-rule-editor', array( 'root' => 'herald-rule-edit-form', 'conditions' => (object)$serial_conditions, 'actions' => (object)$serial_actions, 'select' => array( HeraldAdapter::VALUE_CONTENT_SOURCE => array( 'options' => PhabricatorContentSource::getSourceNameMap(), 'default' => PhabricatorContentSource::SOURCE_WEB, ), HeraldAdapter::VALUE_FLAG_COLOR => array( 'options' => PhabricatorFlagColor::getColorNameMap(), 'default' => PhabricatorFlagColor::COLOR_BLUE, ), HeraldPreCommitRefAdapter::VALUE_REF_TYPE => array( 'options' => array( PhabricatorRepositoryPushLog::REFTYPE_BRANCH => pht('branch (git/hg)'), PhabricatorRepositoryPushLog::REFTYPE_TAG => pht('tag (git)'), PhabricatorRepositoryPushLog::REFTYPE_BOOKMARK => pht('bookmark (hg)'), ), 'default' => PhabricatorRepositoryPushLog::REFTYPE_BRANCH, ), HeraldPreCommitRefAdapter::VALUE_REF_CHANGE => array( 'options' => $changeflag_options, 'default' => PhabricatorRepositoryPushLog::CHANGEFLAG_ADD, ), ), 'template' => $this->buildTokenizerTemplates($handles) + array( 'rules' => $all_rules, ), 'author' => array( $rule->getAuthorPHID() => $handles[$rule->getAuthorPHID()]->getName(), ), 'info' => $config_info, )); } private function loadHandlesForRule($rule) { $phids = array(); foreach ($rule->getActions() as $action) { if (!is_array($action->getTarget())) { continue; } foreach ($action->getTarget() as $target) { $target = (array)$target; foreach ($target as $phid) { $phids[] = $phid; } } } foreach ($rule->getConditions() as $condition) { $value = $condition->getValue(); if (is_array($value)) { foreach ($value as $phid) { $phids[] = $phid; } } } $phids[] = $rule->getAuthorPHID(); if ($rule->isObjectRule()) { $phids[] = $rule->getTriggerObjectPHID(); } return $this->loadViewerHandles($phids); } /** * Render the selector for the "When (all of | any of) these conditions are * met:" element. */ private function renderMustMatchSelector($rule) { return AphrontFormSelectControl::renderSelectTag( $rule->getMustMatchAll() ? 'all' : 'any', array( 'all' => pht('all of'), 'any' => pht('any of'), ), array( 'name' => 'must_match', )); } /** * Render the selector for "Take these actions (every time | only the first * time) this rule matches..." element. */ private function renderRepetitionSelector($rule, HeraldAdapter $adapter) { $repetition_policy = HeraldRepetitionPolicyConfig::toString( $rule->getRepetitionPolicy()); $repetition_options = $adapter->getRepetitionOptions(); $repetition_names = HeraldRepetitionPolicyConfig::getMap(); $repetition_map = array_select_keys($repetition_names, $repetition_options); if (count($repetition_map) < 2) { return head($repetition_names); } else { return AphrontFormSelectControl::renderSelectTag( $repetition_policy, $repetition_map, array( 'name' => 'repetition_policy', )); } } protected function buildTokenizerTemplates(array $handles) { $template = new AphrontTokenizerTemplateView(); $template = $template->render(); $sources = array( 'repository' => new DiffusionRepositoryDatasource(), 'legaldocuments' => new LegalpadDocumentDatasource(), 'taskpriority' => new ManiphestTaskPriorityDatasource(), 'taskstatus' => new ManiphestTaskStatusDatasource(), 'buildplan' => new HarbormasterBuildPlanDatasource(), 'arcanistprojects' => new DiffusionArcanistProjectDatasource(), 'package' => new PhabricatorOwnersPackageDatasource(), 'project' => new PhabricatorProjectDatasource(), 'user' => new PhabricatorPeopleDatasource(), 'email' => new PhabricatorMetaMTAMailableDatasource(), 'userorproject' => new PhabricatorProjectOrUserDatasource(), 'applicationemail' => new PhabricatorMetaMTAApplicationEmailDatasource(), ); foreach ($sources as $key => $source) { + $source->setViewer($this->getViewer()); + $sources[$key] = array( 'uri' => $source->getDatasourceURI(), 'placeholder' => $source->getPlaceholderText(), + 'browseURI' => $source->getBrowseURI(), ); } return array( 'source' => $sources, 'username' => $this->getRequest()->getUser()->getUserName(), 'icons' => mpull($handles, 'getTypeIcon', 'getPHID'), 'markup' => $template, ); } /** * Load rules for the "Another Herald rule..." condition dropdown, which * allows one rule to depend upon the success or failure of another rule. */ private function loadRulesThisRuleMayDependUpon(HeraldRule $rule) { $viewer = $this->getRequest()->getUser(); // Any rule can depend on a global rule. $all_rules = id(new HeraldRuleQuery()) ->setViewer($viewer) ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_GLOBAL)) ->withContentTypes(array($rule->getContentType())) ->execute(); if ($rule->isObjectRule()) { // Object rules may depend on other rules for the same object. $all_rules += id(new HeraldRuleQuery()) ->setViewer($viewer) ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_OBJECT)) ->withContentTypes(array($rule->getContentType())) ->withTriggerObjectPHIDs(array($rule->getTriggerObjectPHID())) ->execute(); } if ($rule->isPersonalRule()) { // Personal rules may depend upon your other personal rules. $all_rules += id(new HeraldRuleQuery()) ->setViewer($viewer) ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_PERSONAL)) ->withContentTypes(array($rule->getContentType())) ->withAuthorPHIDs(array($rule->getAuthorPHID())) ->execute(); } // mark disabled rules as disabled since they are not useful as such; // don't filter though to keep edit cases sane / expected foreach ($all_rules as $current_rule) { if ($current_rule->getIsDisabled()) { $current_rule->makeEphemeral(); $current_rule->setName($rule->getName().' '.pht('(Disabled)')); } } // A rule can not depend upon itself. unset($all_rules[$rule->getID()]); return $all_rules; } } diff --git a/src/applications/mailinglists/typeahead/PhabricatorMailingListDatasource.php b/src/applications/mailinglists/typeahead/PhabricatorMailingListDatasource.php index c08da2a30d..b9435e0b4a 100644 --- a/src/applications/mailinglists/typeahead/PhabricatorMailingListDatasource.php +++ b/src/applications/mailinglists/typeahead/PhabricatorMailingListDatasource.php @@ -1,38 +1,35 @@ getViewer(); $raw_query = $this->getRawQuery(); - $results = array(); + $query = id(new PhabricatorMailingListQuery()); + $lists = $this->executeQuery($query); - $lists = id(new PhabricatorMailingListQuery()) - ->setViewer($viewer) - ->execute(); + $results = array(); foreach ($lists as $list) { $results[] = id(new PhabricatorTypeaheadResult()) ->setName($list->getName()) ->setURI($list->getURI()) ->setPHID($list->getPHID()); } - return $results; + // TODO: It would be slightly preferable to do this as part of the query, + // this is just simpler for the moment. + + return $this->filterResultsAgainstTokens($results); } } diff --git a/src/applications/maniphest/controller/ManiphestBatchEditController.php b/src/applications/maniphest/controller/ManiphestBatchEditController.php index f22269c10e..f604d58d0c 100644 --- a/src/applications/maniphest/controller/ManiphestBatchEditController.php +++ b/src/applications/maniphest/controller/ManiphestBatchEditController.php @@ -1,379 +1,384 @@ requireApplicationCapability( ManiphestBulkEditCapability::CAPABILITY); $request = $this->getRequest(); $user = $request->getUser(); $task_ids = $request->getArr('batch'); $tasks = id(new ManiphestTaskQuery()) ->setViewer($user) ->withIDs($task_ids) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->needSubscriberPHIDs(true) ->needProjectPHIDs(true) ->execute(); $actions = $request->getStr('actions'); if ($actions) { $actions = json_decode($actions, true); } if ($request->isFormPost() && is_array($actions)) { foreach ($tasks as $task) { $field_list = PhabricatorCustomField::getObjectFields( $task, PhabricatorCustomField::ROLE_EDIT); $field_list->readFieldsFromStorage($task); $xactions = $this->buildTransactions($actions, $task); if ($xactions) { // TODO: Set content source to "batch edit". $editor = id(new ManiphestTransactionEditor()) ->setActor($user) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($task, $xactions); } } $task_ids = implode(',', mpull($tasks, 'getID')); return id(new AphrontRedirectResponse()) ->setURI('/maniphest/?ids='.$task_ids); } $handles = ManiphestTaskListView::loadTaskHandles($user, $tasks); $list = new ManiphestTaskListView(); $list->setTasks($tasks); $list->setUser($user); $list->setHandles($handles); $template = new AphrontTokenizerTemplateView(); $template = $template->render(); $projects_source = new PhabricatorProjectDatasource(); $mailable_source = new PhabricatorMetaMTAMailableDatasource(); + $mailable_source->setViewer($user); $owner_source = new PhabricatorTypeaheadOwnerDatasource(); + $owner_source->setViewer($user); require_celerity_resource('maniphest-batch-editor'); Javelin::initBehavior( 'maniphest-batch-editor', array( 'root' => 'maniphest-batch-edit-form', 'tokenizerTemplate' => $template, 'sources' => array( 'project' => array( - 'src' => $projects_source->getDatasourceURI(), - 'placeholder' => $projects_source->getPlaceholderText(), + 'src' => $projects_source->getDatasourceURI(), + 'placeholder' => $projects_source->getPlaceholderText(), + 'browseURI' => $projects_source->getBrowseURI(), ), 'owner' => array( - 'src' => $owner_source->getDatasourceURI(), - 'placeholder' => $owner_source->getPlaceholderText(), - 'limit' => 1, + 'src' => $owner_source->getDatasourceURI(), + 'placeholder' => $owner_source->getPlaceholderText(), + 'browseURI' => $owner_source->getBrowseURI(), + 'limit' => 1, ), - 'cc' => array( - 'src' => $mailable_source->getDatasourceURI(), - 'placeholder' => $mailable_source->getPlaceholderText(), + 'cc' => array( + 'src' => $mailable_source->getDatasourceURI(), + 'placeholder' => $mailable_source->getPlaceholderText(), + 'browseURI' => $mailable_source->getBrowseURI(), ), ), 'input' => 'batch-form-actions', 'priorityMap' => ManiphestTaskPriority::getTaskPriorityMap(), 'statusMap' => ManiphestTaskStatus::getTaskStatusMap(), )); $form = new AphrontFormView(); $form->setUser($user); $form->setID('maniphest-batch-edit-form'); foreach ($tasks as $task) { $form->appendChild( phutil_tag( 'input', array( 'type' => 'hidden', 'name' => 'batch[]', 'value' => $task->getID(), ))); } $form->appendChild( phutil_tag( 'input', array( 'type' => 'hidden', 'name' => 'actions', 'id' => 'batch-form-actions', ))); $form->appendChild( id(new PHUIFormInsetView()) ->setTitle(pht('Actions')) ->setRightButton(javelin_tag( 'a', array( 'href' => '#', 'class' => 'button green', 'sigil' => 'add-action', 'mustcapture' => true, ), pht('Add Another Action'))) ->setContent(javelin_tag( 'table', array( 'sigil' => 'maniphest-batch-actions', 'class' => 'maniphest-batch-actions-table', ), ''))) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Update Tasks')) ->addCancelButton('/maniphest/')); $title = pht('Batch Editor'); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($title); $task_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Selected Tasks')) ->appendChild($list); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Batch Editor')) ->setForm($form); return $this->buildApplicationPage( array( $crumbs, $task_box, $form_box, ), array( 'title' => $title, )); } private function buildTransactions($actions, ManiphestTask $task) { $value_map = array(); $type_map = array( 'add_comment' => PhabricatorTransactions::TYPE_COMMENT, 'assign' => ManiphestTransaction::TYPE_OWNER, 'status' => ManiphestTransaction::TYPE_STATUS, 'priority' => ManiphestTransaction::TYPE_PRIORITY, 'add_project' => PhabricatorTransactions::TYPE_EDGE, 'remove_project' => PhabricatorTransactions::TYPE_EDGE, 'add_ccs' => PhabricatorTransactions::TYPE_SUBSCRIBERS, 'remove_ccs' => PhabricatorTransactions::TYPE_SUBSCRIBERS, ); $edge_edit_types = array( 'add_project' => true, 'remove_project' => true, 'add_ccs' => true, 'remove_ccs' => true, ); $xactions = array(); foreach ($actions as $action) { if (empty($type_map[$action['action']])) { throw new Exception("Unknown batch edit action '{$action}'!"); } $type = $type_map[$action['action']]; // Figure out the current value, possibly after modifications by other // batch actions of the same type. For example, if the user chooses to // "Add Comment" twice, we should add both comments. More notably, if the // user chooses "Remove Project..." and also "Add Project...", we should // avoid restoring the removed project in the second transaction. if (array_key_exists($type, $value_map)) { $current = $value_map[$type]; } else { switch ($type) { case PhabricatorTransactions::TYPE_COMMENT: $current = null; break; case ManiphestTransaction::TYPE_OWNER: $current = $task->getOwnerPHID(); break; case ManiphestTransaction::TYPE_STATUS: $current = $task->getStatus(); break; case ManiphestTransaction::TYPE_PRIORITY: $current = $task->getPriority(); break; case PhabricatorTransactions::TYPE_EDGE: $current = $task->getProjectPHIDs(); break; case PhabricatorTransactions::TYPE_SUBSCRIBERS: $current = $task->getSubscriberPHIDs(); break; } } // Check if the value is meaningful / provided, and normalize it if // necessary. This discards, e.g., empty comments and empty owner // changes. $value = $action['value']; switch ($type) { case PhabricatorTransactions::TYPE_COMMENT: if (!strlen($value)) { continue 2; } break; case ManiphestTransaction::TYPE_OWNER: if (empty($value)) { continue 2; } $value = head($value); if ($value === ManiphestTaskOwner::OWNER_UP_FOR_GRABS) { $value = null; } break; case PhabricatorTransactions::TYPE_EDGE: if (empty($value)) { continue 2; } break; case PhabricatorTransactions::TYPE_SUBSCRIBERS: if (empty($value)) { continue 2; } break; } // If the edit doesn't change anything, go to the next action. This // check is only valid for changes like "owner", "status", etc, not // for edge edits, because we should still apply an edit like // "Remove Projects: A, B" to a task with projects "A, B". if (empty($edge_edit_types[$action['action']])) { if ($value == $current) { continue; } } // Apply the value change; for most edits this is just replacement, but // some need to merge the current and edited values (add/remove project). switch ($type) { case PhabricatorTransactions::TYPE_COMMENT: if (strlen($current)) { $value = $current."\n\n".$value; } break; case PhabricatorTransactions::TYPE_EDGE: $is_remove = $action['action'] == 'remove_project'; $current = array_fill_keys($current, true); $value = array_fill_keys($value, true); $new = $current; $did_something = false; if ($is_remove) { foreach ($value as $phid => $ignored) { if (isset($new[$phid])) { unset($new[$phid]); $did_something = true; } } } else { foreach ($value as $phid => $ignored) { if (empty($new[$phid])) { $new[$phid] = true; $did_something = true; } } } if (!$did_something) { continue 2; } $value = array_keys($new); break; case PhabricatorTransactions::TYPE_SUBSCRIBERS: $is_remove = $action['action'] == 'remove_ccs'; $current = array_fill_keys($current, true); $new = array(); $did_something = false; if ($is_remove) { foreach ($value as $phid) { if (isset($current[$phid])) { $new[$phid] = true; $did_something = true; } } if ($new) { $value = array('-' => array_keys($new)); } } else { $new = array(); foreach ($value as $phid) { $new[$phid] = true; $did_something = true; } if ($new) { $value = array('+' => array_keys($new)); } } if (!$did_something) { continue 2; } break; } $value_map[$type] = $value; } $template = new ManiphestTransaction(); foreach ($value_map as $type => $value) { $xaction = clone $template; $xaction->setTransactionType($type); switch ($type) { case PhabricatorTransactions::TYPE_COMMENT: $xaction->attachComment( id(new ManiphestTransactionComment()) ->setContent($value)); break; case PhabricatorTransactions::TYPE_EDGE: $project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; $xaction ->setMetadataValue('edge:type', $project_type) ->setNewValue( array( '=' => array_fuse($value), )); break; default: $xaction->setNewValue($value); break; } $xactions[] = $xaction; } return $xactions; } } diff --git a/src/applications/policy/rule/PhabricatorLegalpadSignaturePolicyRule.php b/src/applications/policy/rule/PhabricatorLegalpadSignaturePolicyRule.php index 14d207b4d8..9797d5ef80 100644 --- a/src/applications/policy/rule/PhabricatorLegalpadSignaturePolicyRule.php +++ b/src/applications/policy/rule/PhabricatorLegalpadSignaturePolicyRule.php @@ -1,74 +1,68 @@ setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($values) ->withSignerPHIDs(array($viewer->getPHID())) ->execute(); $this->signatures = mpull($documents, 'getPHID', 'getPHID'); } public function applyRule(PhabricatorUser $viewer, $value) { foreach ($value as $document_phid) { if (!isset($this->signatures[$document_phid])) { return false; } } return true; } public function getValueControlType() { return self::CONTROL_TYPE_TOKENIZER; } public function getValueControlTemplate() { - $datasource = new LegalpadDocumentDatasource(); - - return array( - 'markup' => new AphrontTokenizerTemplateView(), - 'uri' => $datasource->getDatasourceURI(), - 'placeholder' => $datasource->getPlaceholderText(), - ); + return $this->getDatasourceTemplate(new LegalpadDocumentDatasource()); } public function getRuleOrder() { return 900; } public function getValueForStorage($value) { PhutilTypeSpec::newFromString('list')->check($value); return array_values($value); } public function getValueForDisplay(PhabricatorUser $viewer, $value) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs($value) ->execute(); return mpull($handles, 'getFullName', 'getPHID'); } public function ruleHasEffect($value) { return (bool)$value; } } diff --git a/src/applications/policy/rule/PhabricatorPolicyRule.php b/src/applications/policy/rule/PhabricatorPolicyRule.php index 6540d797df..eaa04e1e9e 100644 --- a/src/applications/policy/rule/PhabricatorPolicyRule.php +++ b/src/applications/policy/rule/PhabricatorPolicyRule.php @@ -1,70 +1,80 @@ new AphrontTokenizerTemplateView(), + 'uri' => $datasource->getDatasourceURI(), + 'placeholder' => $datasource->getPlaceholderText(), + 'browseURI' => $datasource->getBrowseURI(), + ); + } + public function getRuleOrder() { return 500; } public function getValueForStorage($value) { return $value; } public function getValueForDisplay(PhabricatorUser $viewer, $value) { return $value; } public function getRequiredHandlePHIDsForSummary($value) { $phids = array(); switch ($this->getValueControlType()) { case self::CONTROL_TYPE_TOKENIZER: $phids = $value; break; case self::CONTROL_TYPE_TEXT: case self::CONTROL_TYPE_SELECT: case self::CONTROL_TYPE_NONE: default: if (phid_get_type($value) != PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) { $phids = array($value); } else { $phids = array(); } break; } return $phids; } /** * Return true if the given value creates a rule with a meaningful effect. * An example of a rule with no meaningful effect is a "users" rule with no * users specified. * * @return bool True if the value creates a meaningful rule. */ public function ruleHasEffect($value) { return true; } } diff --git a/src/applications/policy/rule/PhabricatorProjectsPolicyRule.php b/src/applications/policy/rule/PhabricatorProjectsPolicyRule.php index 7bd75495c8..eca97ce37d 100644 --- a/src/applications/policy/rule/PhabricatorProjectsPolicyRule.php +++ b/src/applications/policy/rule/PhabricatorProjectsPolicyRule.php @@ -1,72 +1,66 @@ setViewer(PhabricatorUser::getOmnipotentUser()) ->withMemberPHIDs(array($viewer->getPHID())) ->withPHIDs($values) ->execute(); foreach ($projects as $project) { $this->memberships[$viewer->getPHID()][$project->getPHID()] = true; } } public function applyRule(PhabricatorUser $viewer, $value) { foreach ($value as $project_phid) { if (isset($this->memberships[$viewer->getPHID()][$project_phid])) { return true; } } return false; } public function getValueControlType() { return self::CONTROL_TYPE_TOKENIZER; } public function getValueControlTemplate() { - $projects_source = new PhabricatorProjectDatasource(); - - return array( - 'markup' => new AphrontTokenizerTemplateView(), - 'uri' => $projects_source->getDatasourceURI(), - 'placeholder' => $projects_source->getPlaceholderText(), - ); + return $this->getDatasourceTemplate(new PhabricatorProjectDatasource()); } public function getRuleOrder() { return 200; } public function getValueForStorage($value) { PhutilTypeSpec::newFromString('list')->check($value); return array_values($value); } public function getValueForDisplay(PhabricatorUser $viewer, $value) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs($value) ->execute(); return mpull($handles, 'getFullName', 'getPHID'); } public function ruleHasEffect($value) { return (bool)$value; } } diff --git a/src/applications/policy/rule/PhabricatorUsersPolicyRule.php b/src/applications/policy/rule/PhabricatorUsersPolicyRule.php index 03be81e064..c60e43abb6 100644 --- a/src/applications/policy/rule/PhabricatorUsersPolicyRule.php +++ b/src/applications/policy/rule/PhabricatorUsersPolicyRule.php @@ -1,58 +1,52 @@ getPHID()) { return true; } } return false; } public function getValueControlType() { return self::CONTROL_TYPE_TOKENIZER; } public function getValueControlTemplate() { - $users_datasource = new PhabricatorPeopleDatasource(); - - return array( - 'markup' => new AphrontTokenizerTemplateView(), - 'uri' => $users_datasource->getDatasourceURI(), - 'placeholder' => $users_datasource->getPlaceholderText(), - ); + return $this->getDatasourceTemplate(new PhabricatorPeopleDatasource()); } public function getRuleOrder() { return 100; } public function getValueForStorage($value) { PhutilTypeSpec::newFromString('list')->check($value); return array_values($value); } public function getValueForDisplay(PhabricatorUser $viewer, $value) { if (!$value) { return array(); } $handles = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs($value) ->execute(); return mpull($handles, 'getFullName', 'getPHID'); } public function ruleHasEffect($value) { return (bool)$value; } } diff --git a/src/view/control/AphrontTokenizerTemplateView.php b/src/view/control/AphrontTokenizerTemplateView.php index 009f3e134e..f0e34f2b1e 100644 --- a/src/view/control/AphrontTokenizerTemplateView.php +++ b/src/view/control/AphrontTokenizerTemplateView.php @@ -1,131 +1,136 @@ browseURI = $browse_uri; return $this; } public function setID($id) { $this->id = $id; return $this; } public function setValue(array $value) { assert_instances_of($value, 'PhabricatorObjectHandle'); $this->value = $value; return $this; } public function getValue() { return $this->value; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function render() { require_celerity_resource('aphront-tokenizer-control-css'); $id = $this->id; $name = $this->getName(); $values = nonempty($this->getValue(), array()); $tokens = array(); foreach ($values as $key => $value) { $tokens[] = $this->renderToken( $value->getPHID(), $value->getFullName(), $value->getType()); } $input = javelin_tag( 'input', array( 'mustcapture' => true, 'name' => $name, 'class' => 'jx-tokenizer-input', 'sigil' => 'tokenizer-input', 'style' => 'width: 0px;', 'disabled' => 'disabled', 'type' => 'text', )); $content = $tokens; $content[] = $input; $content[] = phutil_tag('div', array('style' => 'clear: both;'), ''); - $container = phutil_tag( + $container = javelin_tag( 'div', array( 'id' => $id, 'class' => 'jx-tokenizer-container', + 'sigil' => 'tokenizer-container', ), $content); - $browse = null; + $icon = id(new PHUIIconView()) + ->setIconFont('fa-list-ul'); + + // TODO: This thing is ugly and the ugliness is not intentional. + // We have to give it text or PHUIButtonView collapses. It should likely + // just be an icon and look more integrated into the input. + $browse = id(new PHUIButtonView()) + ->setTag('a') + ->setIcon($icon) + ->addSigil('tokenizer-browse') + ->setColor(PHUIButtonView::GREY) + ->setSize(PHUIButtonView::SMALL) + ->setText(pht('Browse...')); + + $classes = array(); + $classes[] = 'jx-tokenizer-frame'; + if ($this->browseURI) { - $icon = id(new PHUIIconView()) - ->setIconFont('fa-list-ul'); - - // TODO: This thing is ugly and the ugliness is not intentional. - // We have to give it text or PHUIButtonView collapses. It should likely - // just be an icon and look more integrated into the input. - $browse = id(new PHUIButtonView()) - ->setTag('a') - ->setIcon($icon) - ->addSigil('tokenizer-browse') - ->setColor(PHUIButtonView::GREY) - ->setSize(PHUIButtonView::SMALL) - ->setText(pht('Browse...')); + $classes[] = 'has-browse'; } $frame = javelin_tag( 'table', array( - 'class' => 'jx-tokenizer-frame', + 'class' => implode(' ', $classes), 'sigil' => 'tokenizer-frame', ), phutil_tag( 'tr', array( ), array( phutil_tag( 'td', array( 'class' => 'jx-tokenizer-frame-input', ), $container), phutil_tag( 'td', array( 'class' => 'jx-tokenizer-frame-browse', ), $browse), ))); return $frame; } private function renderToken($key, $value, $icon) { return id(new PhabricatorTypeaheadTokenView()) ->setKey($key) ->setValue($value) ->setIcon($icon) ->setInputName($this->getName()); } } diff --git a/webroot/rsrc/css/aphront/tokenizer.css b/webroot/rsrc/css/aphront/tokenizer.css index 074f54e5d8..2056c88c31 100644 --- a/webroot/rsrc/css/aphront/tokenizer.css +++ b/webroot/rsrc/css/aphront/tokenizer.css @@ -1,120 +1,128 @@ /** * @provides aphront-tokenizer-control-css * @requires aphront-typeahead-control-css */ body div.jx-tokenizer { background: transparent; position: relative; width: 100%; } body div.jx-tokenizer-container { position: relative; display: block; padding: 0 0 2px 0; min-height: 30px; height: auto; } var.jx-tokenizer-metrics { position: absolute; left: 20px; top: 20px; } body input.jx-tokenizer-input { border: 1px solid transparent; border-width: 1px 0px; padding: 3px; outline: none; float: left; width: 100%; border-shadow: none; box-shadow: none; -webkit-box-shadow: none; font-size: 13px; color: #333; height: 26px; } body input.jx-tokenizer-input:focus { box-shadow: none; -webkit-box-shadow: none; border-color: transparent; } body input.jx-typeahead-placeholder { margin-left: 4px; color: {$greytext}; } a.jx-tokenizer-x { margin-left: 4px; color: {$bluetext}; } a.jx-tokenizer-x:hover { color: {$darkbluetext}; text-decoration: none; } a.jx-tokenizer-token { padding: 2px 6px 3px; border: 1px solid {$lightblueborder}; margin: 3px 2px 0 4px; background: #dee7f8; float: left; cursor: pointer; border-radius: 3px; color: {$darkbluetext}; min-height: 16px; } a.jx-tokenizer-token:hover { text-decoration: none; border-color: {$blueborder}; background: #CDD9F0; } .jx-tokenizer-token .phui-icon-view { display: inline-block; margin: 2px 4px -3px 0; color: {$bluetext}; } .tokenizer-result { position: relative; padding: 5px 8px 5px 28px; } .tokenizer-result .phui-icon-view { display: inline-block; width: 24px; height: 24px; position: absolute; top: 5px; left: 8px; } .tokenizer-result-closed { color: {$greytext}; } .tokenizer-closed { margin-top: 2px; } .jx-tokenizer-frame { width: 100%; } -.jx-tokenizer-frame-input { +.jx-tokenizer-frame .jx-tokenizer-frame-browse { + display: none; +} + +.has-browse .jx-tokenizer-frame-browse { + display: table-cell; +} + +.jx-tokenizer-frame td.jx-tokenizer-frame-input { width: 100%; } .jx-tokenizer-frame-browse { width: 100px; vertical-align: middle; padding: 0 0 0 4px; } diff --git a/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js b/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js index bdde22fb88..93e540ece3 100644 --- a/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js +++ b/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js @@ -1,469 +1,470 @@ /** * @requires javelin-dom * javelin-util * javelin-stratcom * javelin-install * @provides javelin-tokenizer * @javelin */ /** * A tokenizer is a UI component similar to a text input, except that it * allows the user to input a list of items ("tokens"), generally from a fixed * set of results. A familiar example of this UI is the "To:" field of most * email clients, where the control autocompletes addresses from the user's * address book. * * @{JX.Tokenizer} is built on top of @{JX.Typeahead}, and primarily adds the * ability to choose multiple items. * * To build a @{JX.Tokenizer}, you need to do four things: * * 1. Construct it, padding a DOM node for it to attach to. See the constructor * for more information. * 2. Build a {@JX.Typeahead} and configure it with setTypeahead(). * 3. Configure any special options you want. * 4. Call start(). * * If you do this correctly, the input should suggest items and enter them as * tokens as the user types. * * When the tokenizer is focused, the CSS class `jx-tokenizer-container-focused` * is added to the container node. */ JX.install('Tokenizer', { construct : function(containerNode) { this._containerNode = containerNode; }, events : [ /** * Emitted when the value of the tokenizer changes, similar to an 'onchange' * from a