@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 745 lines 23 kB view raw
1<?php 2 3final class HeraldRuleController extends HeraldController { 4 5 public function handleRequest(AphrontRequest $request) { 6 $viewer = $request->getViewer(); 7 $id = $request->getURIData('id'); 8 9 $content_type_map = HeraldAdapter::getEnabledAdapterMap($viewer); 10 $rule_type_map = HeraldRuleTypeConfig::getRuleTypeMap(); 11 12 if ($id) { 13 $rule = id(new HeraldRuleQuery()) 14 ->setViewer($viewer) 15 ->withIDs(array($id)) 16 ->requireCapabilities( 17 array( 18 PhabricatorPolicyCapability::CAN_VIEW, 19 PhabricatorPolicyCapability::CAN_EDIT, 20 )) 21 ->executeOne(); 22 if (!$rule) { 23 return new Aphront404Response(); 24 } 25 $cancel_uri = '/'.$rule->getMonogram(); 26 } else { 27 $new_uri = $this->getApplicationURI('new/'); 28 29 $rule = new HeraldRule(); 30 $rule->setAuthorPHID($viewer->getPHID()); 31 $rule->setMustMatchAll(1); 32 33 $content_type = $request->getStr('content_type'); 34 $rule->setContentType($content_type); 35 36 $rule_type = $request->getStr('rule_type'); 37 if (!isset($rule_type_map[$rule_type])) { 38 return $this->newDialog() 39 ->setTitle(pht('Invalid Rule Type')) 40 ->appendParagraph( 41 pht( 42 'The selected rule type ("%s") is not recognized by Herald.', 43 $rule_type)) 44 ->addCancelButton($new_uri); 45 } 46 $rule->setRuleType($rule_type); 47 48 try { 49 $adapter = HeraldAdapter::getAdapterForContentType( 50 $rule->getContentType()); 51 } catch (Exception $ex) { 52 return $this->newDialog() 53 ->setTitle(pht('Invalid Content Type')) 54 ->appendParagraph( 55 pht( 56 'The selected content type ("%s") is not recognized by '. 57 'Herald.', 58 $rule->getContentType())) 59 ->addCancelButton($new_uri); 60 } 61 62 if (!$adapter->supportsRuleType($rule->getRuleType())) { 63 return $this->newDialog() 64 ->setTitle(pht('Rule/Content Mismatch')) 65 ->appendParagraph( 66 pht( 67 'The selected rule type ("%s") is not supported by the selected '. 68 'content type ("%s").', 69 $rule->getRuleType(), 70 $rule->getContentType())) 71 ->addCancelButton($new_uri); 72 } 73 74 if ($rule->isObjectRule()) { 75 $rule->setTriggerObjectPHID($request->getStr('targetPHID')); 76 $object = id(new PhabricatorObjectQuery()) 77 ->setViewer($viewer) 78 ->withPHIDs(array($rule->getTriggerObjectPHID())) 79 ->requireCapabilities( 80 array( 81 PhabricatorPolicyCapability::CAN_VIEW, 82 PhabricatorPolicyCapability::CAN_EDIT, 83 )) 84 ->executeOne(); 85 if (!$object) { 86 throw new Exception( 87 pht('No valid object provided for object rule!')); 88 } 89 90 if (!$adapter->canTriggerOnObject($object)) { 91 throw new Exception( 92 pht('Object is of wrong type for adapter!')); 93 } 94 } 95 96 $cancel_uri = $this->getApplicationURI(); 97 } 98 99 if ($rule->isGlobalRule()) { 100 $this->requireApplicationCapability( 101 HeraldManageGlobalRulesCapability::CAPABILITY); 102 } 103 104 $adapter = HeraldAdapter::getAdapterForContentType($rule->getContentType()); 105 106 $local_version = id(new HeraldRule())->getConfigVersion(); 107 if ($rule->getConfigVersion() > $local_version) { 108 throw new Exception( 109 pht( 110 'This rule was created with a newer version of Herald. You can not '. 111 'view or edit it in this older version. Upgrade your software.')); 112 } 113 114 // Upgrade rule version to our version, since we might add newly-defined 115 // conditions, etc. 116 $rule->setConfigVersion($local_version); 117 118 $rule_conditions = $rule->loadConditions(); 119 $rule_actions = $rule->loadActions(); 120 121 $rule->attachConditions($rule_conditions); 122 $rule->attachActions($rule_actions); 123 124 $e_name = true; 125 $errors = array(); 126 if ($request->isFormPost() && $request->getStr('save')) { 127 list($e_name, $errors) = $this->saveRule($adapter, $rule, $request); 128 if (!$errors) { 129 $id = $rule->getID(); 130 $uri = '/'.$rule->getMonogram(); 131 return id(new AphrontRedirectResponse())->setURI($uri); 132 } 133 } 134 135 $must_match_selector = $this->renderMustMatchSelector($rule); 136 $repetition_selector = $this->renderRepetitionSelector($rule, $adapter); 137 138 $handles = $this->loadHandlesForRule($rule); 139 140 require_celerity_resource('herald-css'); 141 142 $content_type_name = $content_type_map[$rule->getContentType()]; 143 $rule_type_name = $rule_type_map[$rule->getRuleType()]; 144 145 $form = id(new AphrontFormView()) 146 ->setUser($viewer) 147 ->setID('herald-rule-edit-form') 148 ->addHiddenInput('content_type', $rule->getContentType()) 149 ->addHiddenInput('rule_type', $rule->getRuleType()) 150 ->addHiddenInput('save', 1) 151 ->appendChild( 152 // Build this explicitly (instead of using addHiddenInput()) 153 // so we can add a sigil to it. 154 javelin_tag( 155 'input', 156 array( 157 'type' => 'hidden', 158 'name' => 'rule', 159 'sigil' => 'rule', 160 ))) 161 ->appendChild( 162 id(new AphrontFormTextControl()) 163 ->setLabel(pht('Rule Name')) 164 ->setName('name') 165 ->setError($e_name) 166 ->setValue($rule->getName())); 167 168 $trigger_object_control = false; 169 if ($rule->isObjectRule()) { 170 $trigger_object_control = id(new AphrontFormStaticControl()) 171 ->setValue( 172 pht( 173 'This rule triggers for %s.', 174 $handles[$rule->getTriggerObjectPHID()]->renderLink())); 175 } 176 177 178 $form 179 ->appendChild( 180 id(new AphrontFormMarkupControl()) 181 ->setValue(pht( 182 'This %s rule triggers for %s.', 183 phutil_tag('strong', array(), $rule_type_name), 184 phutil_tag('strong', array(), $content_type_name)))) 185 ->appendChild($trigger_object_control) 186 ->appendChild( 187 id(new PHUIFormInsetView()) 188 ->setTitle(pht('Conditions')) 189 ->setRightButton(javelin_tag( 190 'a', 191 array( 192 'href' => '#', 193 'class' => 'button button-green', 194 'sigil' => 'create-condition', 195 'mustcapture' => true, 196 ), 197 pht('New Condition'))) 198 ->setDescription( 199 pht('When %s these conditions are met:', $must_match_selector)) 200 ->setContent(javelin_tag( 201 'table', 202 array( 203 'sigil' => 'rule-conditions', 204 'class' => 'herald-condition-table', 205 ), 206 ''))) 207 ->appendChild( 208 id(new PHUIFormInsetView()) 209 ->setTitle(pht('Action')) 210 ->setRightButton(javelin_tag( 211 'a', 212 array( 213 'href' => '#', 214 'class' => 'button button-green', 215 'sigil' => 'create-action', 216 'mustcapture' => true, 217 ), 218 pht('New Action'))) 219 ->setDescription(pht( 220 'Take these actions %s', 221 $repetition_selector)) 222 ->setContent(javelin_tag( 223 'table', 224 array( 225 'sigil' => 'rule-actions', 226 'class' => 'herald-action-table', 227 ), 228 ''))) 229 ->appendChild( 230 id(new AphrontFormSubmitControl()) 231 ->setValue(pht('Save Rule')) 232 ->addCancelButton($cancel_uri)); 233 234 $this->setupEditorBehavior($rule, $handles, $adapter); 235 236 $title = $rule->getID() 237 ? pht('Edit Herald Rule: %s', $rule->getName()) 238 : pht('Create Herald Rule: %s', idx($content_type_map, $content_type)); 239 240 $form_box = id(new PHUIObjectBoxView()) 241 ->setHeaderText($title) 242 ->setBackground(PHUIObjectBoxView::WHITE_CONFIG) 243 ->setFormErrors($errors) 244 ->setForm($form); 245 246 $crumbs = $this 247 ->buildApplicationCrumbs() 248 ->addTextCrumb($title) 249 ->setBorder(true); 250 251 $view = id(new PHUITwoColumnView()) 252 ->setFooter($form_box); 253 254 return $this->newPage() 255 ->setTitle($title) 256 ->setCrumbs($crumbs) 257 ->appendChild( 258 array( 259 $view, 260 )); 261 } 262 263 private function saveRule(HeraldAdapter $adapter, $rule, $request) { 264 $new_name = $request->getStr('name'); 265 $match_all = ($request->getStr('must_match') == 'all'); 266 267 $repetition_policy = $request->getStr('repetition_policy'); 268 269 // If the user selected an invalid policy, or there's only one possible 270 // value so we didn't render a control, adjust the value to the first 271 // valid policy value. 272 $repetition_options = $this->getRepetitionOptionMap($adapter); 273 if (!$repetition_policy || 274 !isset($repetition_options[$repetition_policy])) { 275 $repetition_policy = head_key($repetition_options); 276 } 277 278 $e_name = true; 279 $errors = array(); 280 281 if (!strlen($new_name)) { 282 $e_name = pht('Required'); 283 $errors[] = pht('Rule must have a name.'); 284 } 285 286 $data = null; 287 try { 288 $data = phutil_json_decode($request->getStr('rule')); 289 } catch (PhutilJSONParserException $ex) { 290 throw new Exception( 291 pht('Failed to decode rule data.'), 292 0, 293 $ex); 294 } 295 296 if (!is_array($data) || 297 !$data['conditions'] || 298 !$data['actions']) { 299 throw new Exception(pht('Failed to decode rule data.')); 300 } 301 302 $conditions = array(); 303 foreach ($data['conditions'] as $condition) { 304 if ($condition === null) { 305 // We manage this as a sparse array on the client, so may receive 306 // NULL if conditions have been removed. 307 continue; 308 } 309 310 $obj = new HeraldCondition(); 311 $obj->setFieldName($condition[0]); 312 $obj->setFieldCondition($condition[1]); 313 314 if (is_array($condition[2])) { 315 $obj->setValue(array_keys($condition[2])); 316 } else { 317 $obj->setValue($condition[2]); 318 } 319 320 try { 321 $adapter->willSaveCondition($obj); 322 } catch (HeraldInvalidConditionException $ex) { 323 $errors[] = $ex->getMessage(); 324 } 325 326 $conditions[] = $obj; 327 } 328 329 $actions = array(); 330 foreach ($data['actions'] as $action) { 331 if ($action === null) { 332 // Sparse on the client; removals can give us NULLs. 333 continue; 334 } 335 336 if (!isset($action[1])) { 337 // Legitimate for any action which doesn't need a target, like 338 // "Do nothing". 339 $action[1] = null; 340 } 341 342 $obj = new HeraldActionRecord(); 343 $obj->setAction($action[0]); 344 $obj->setTarget($action[1]); 345 346 try { 347 $adapter->willSaveAction($rule, $obj); 348 } catch (HeraldInvalidActionException $ex) { 349 $errors[] = $ex->getMessage(); 350 } 351 352 $actions[] = $obj; 353 } 354 355 if (!$errors) { 356 $new_state = id(new HeraldRuleSerializer())->serializeRuleComponents( 357 $match_all, 358 $conditions, 359 $actions, 360 $repetition_policy); 361 362 $xactions = array(); 363 364 // Until this moves to EditEngine, manually add a "CREATE" transaction 365 // if we're creating a new rule. This improves rendering of the initial 366 // group of transactions. 367 $is_new = (bool)(!$rule->getID()); 368 if ($is_new) { 369 $xactions[] = id(new HeraldRuleTransaction()) 370 ->setTransactionType(PhabricatorTransactions::TYPE_CREATE); 371 } 372 373 $xactions[] = id(new HeraldRuleTransaction()) 374 ->setTransactionType(HeraldRuleEditTransaction::TRANSACTIONTYPE) 375 ->setNewValue($new_state); 376 $xactions[] = id(new HeraldRuleTransaction()) 377 ->setTransactionType(HeraldRuleNameTransaction::TRANSACTIONTYPE) 378 ->setNewValue($new_name); 379 380 try { 381 id(new HeraldRuleEditor()) 382 ->setActor($this->getViewer()) 383 ->setContinueOnNoEffect(true) 384 ->setContentSourceFromRequest($request) 385 ->applyTransactions($rule, $xactions); 386 return array(null, null); 387 } catch (Exception $ex) { 388 $errors[] = $ex->getMessage(); 389 } 390 } 391 392 // mutate current rule, so it would be sent to the client in the right state 393 $rule->setMustMatchAll((int)$match_all); 394 $rule->setName($new_name); 395 $rule->setRepetitionPolicyStringConstant($repetition_policy); 396 $rule->attachConditions($conditions); 397 $rule->attachActions($actions); 398 399 return array($e_name, $errors); 400 } 401 402 private function setupEditorBehavior( 403 HeraldRule $rule, 404 array $handles, 405 HeraldAdapter $adapter) { 406 407 $all_rules = $this->loadRulesThisRuleMayDependUpon($rule); 408 $all_rules = msortv($all_rules, 'getEditorSortVector'); 409 $all_rules = mpull($all_rules, 'getEditorDisplayName', 'getPHID'); 410 411 $all_fields = $adapter->getFieldNameMap(); 412 $all_conditions = $adapter->getConditionNameMap(); 413 $all_actions = $adapter->getActionNameMap($rule->getRuleType()); 414 415 $fields = $adapter->getFields(); 416 $field_map = array_select_keys($all_fields, $fields); 417 418 // Populate any fields which exist in the rule but which we don't know the 419 // names of, so that saving a rule without touching anything doesn't change 420 // it. 421 foreach ($rule->getConditions() as $condition) { 422 $field_name = $condition->getFieldName(); 423 424 if (empty($field_map[$field_name])) { 425 $field_map[$field_name] = pht('<Unknown Field "%s">', $field_name); 426 } 427 } 428 429 $actions = $adapter->getActions($rule->getRuleType()); 430 $action_map = array_select_keys($all_actions, $actions); 431 432 // Populate any actions which exist in the rule but which we don't know the 433 // names of, so that saving a rule without touching anything doesn't change 434 // it. 435 foreach ($rule->getActions() as $action) { 436 $action_name = $action->getAction(); 437 438 if (empty($action_map[$action_name])) { 439 $action_map[$action_name] = pht('<Unknown Action "%s">', $action_name); 440 } 441 } 442 443 $config_info = array(); 444 $config_info['fields'] = $this->getFieldGroups($adapter, $field_map); 445 $config_info['conditions'] = $all_conditions; 446 $config_info['actions'] = $this->getActionGroups($adapter, $action_map); 447 $config_info['valueMap'] = array(); 448 449 foreach ($field_map as $field => $name) { 450 try { 451 $field_conditions = $adapter->getConditionsForField($field); 452 } catch (Exception $ex) { 453 $field_conditions = array(HeraldAdapter::CONDITION_UNCONDITIONALLY); 454 } 455 $config_info['conditionMap'][$field] = $field_conditions; 456 } 457 458 foreach ($field_map as $field => $fname) { 459 foreach ($config_info['conditionMap'][$field] as $condition) { 460 $value_key = $adapter->getValueTypeForFieldAndCondition( 461 $field, 462 $condition); 463 464 if ($value_key instanceof HeraldFieldValue) { 465 $value_key->setViewer($this->getViewer()); 466 467 $spec = $value_key->getControlSpecificationDictionary(); 468 $value_key = $value_key->getFieldValueKey(); 469 $config_info['valueMap'][$value_key] = $spec; 470 } 471 472 $config_info['values'][$field][$condition] = $value_key; 473 } 474 } 475 476 $config_info['rule_type'] = $rule->getRuleType(); 477 478 foreach ($action_map as $action => $name) { 479 try { 480 $value_key = $adapter->getValueTypeForAction( 481 $action, 482 $rule->getRuleType()); 483 } catch (Exception $ex) { 484 $value_key = new HeraldEmptyFieldValue(); 485 } 486 487 if ($value_key instanceof HeraldFieldValue) { 488 $value_key->setViewer($this->getViewer()); 489 490 $spec = $value_key->getControlSpecificationDictionary(); 491 $value_key = $value_key->getFieldValueKey(); 492 $config_info['valueMap'][$value_key] = $spec; 493 } 494 495 $config_info['targets'][$action] = $value_key; 496 } 497 498 $default_group = head($config_info['fields']); 499 $default_field = head_key($default_group['options']); 500 $default_condition = head($config_info['conditionMap'][$default_field]); 501 $default_actions = head($config_info['actions']); 502 $default_action = head_key($default_actions['options']); 503 504 if ($rule->getConditions()) { 505 $serial_conditions = array(); 506 foreach ($rule->getConditions() as $condition) { 507 $value = $adapter->getEditorValueForCondition( 508 $this->getViewer(), 509 $condition); 510 511 $serial_conditions[] = array( 512 $condition->getFieldName(), 513 $condition->getFieldCondition(), 514 $value, 515 ); 516 } 517 } else { 518 $serial_conditions = array( 519 array($default_field, $default_condition, null), 520 ); 521 } 522 523 if ($rule->getActions()) { 524 $serial_actions = array(); 525 foreach ($rule->getActions() as $action) { 526 $value = $adapter->getEditorValueForAction( 527 $this->getViewer(), 528 $action); 529 530 $serial_actions[] = array( 531 $action->getAction(), 532 $value, 533 ); 534 } 535 } else { 536 $serial_actions = array( 537 array($default_action, null), 538 ); 539 } 540 541 Javelin::initBehavior( 542 'herald-rule-editor', 543 array( 544 'root' => 'herald-rule-edit-form', 545 'default' => array( 546 'field' => $default_field, 547 'condition' => $default_condition, 548 'action' => $default_action, 549 ), 550 'conditions' => (object)$serial_conditions, 551 'actions' => (object)$serial_actions, 552 'template' => $this->buildTokenizerTemplates() + array( 553 'rules' => $all_rules, 554 ), 555 'info' => $config_info, 556 )); 557 } 558 559 private function loadHandlesForRule($rule) { 560 $phids = array(); 561 562 foreach ($rule->getActions() as $action) { 563 if (!is_array($action->getTarget())) { 564 continue; 565 } 566 foreach ($action->getTarget() as $target) { 567 $target = (array)$target; 568 foreach ($target as $phid) { 569 $phids[] = $phid; 570 } 571 } 572 } 573 574 foreach ($rule->getConditions() as $condition) { 575 $value = $condition->getValue(); 576 if (is_array($value)) { 577 foreach ($value as $phid) { 578 $phids[] = $phid; 579 } 580 } 581 } 582 583 $phids[] = $rule->getAuthorPHID(); 584 585 if ($rule->isObjectRule()) { 586 $phids[] = $rule->getTriggerObjectPHID(); 587 } 588 589 return $this->loadViewerHandles($phids); 590 } 591 592 593 /** 594 * Render the selector for the "When (all of | any of) these conditions are 595 * met:" element. 596 */ 597 private function renderMustMatchSelector($rule) { 598 return AphrontFormSelectControl::renderSelectTag( 599 $rule->getMustMatchAll() ? 'all' : 'any', 600 array( 601 'all' => pht('all of'), 602 'any' => pht('any of'), 603 ), 604 array( 605 'name' => 'must_match', 606 )); 607 } 608 609 610 /** 611 * Render the selector for "Take these actions (every time | only the first 612 * time) this rule matches..." element. 613 */ 614 private function renderRepetitionSelector($rule, HeraldAdapter $adapter) { 615 $repetition_policy = $rule->getRepetitionPolicyStringConstant(); 616 $repetition_map = $this->getRepetitionOptionMap($adapter); 617 if (count($repetition_map) < 2) { 618 return head($repetition_map); 619 } else { 620 return AphrontFormSelectControl::renderSelectTag( 621 $repetition_policy, 622 $repetition_map, 623 array( 624 'name' => 'repetition_policy', 625 )); 626 } 627 } 628 629 private function getRepetitionOptionMap(HeraldAdapter $adapter) { 630 $repetition_options = $adapter->getRepetitionOptions(); 631 $repetition_names = HeraldRule::getRepetitionPolicySelectOptionMap(); 632 return array_select_keys($repetition_names, $repetition_options); 633 } 634 635 protected function buildTokenizerTemplates() { 636 $template = new AphrontTokenizerTemplateView(); 637 $template = $template->render(); 638 return array( 639 'markup' => $template, 640 ); 641 } 642 643 644 /** 645 * Load rules for the "Another Herald rule..." condition dropdown, which 646 * allows one rule to depend upon the success or failure of another rule. 647 */ 648 private function loadRulesThisRuleMayDependUpon(HeraldRule $rule) { 649 $viewer = $this->getRequest()->getUser(); 650 651 // Any rule can depend on a global rule. 652 $all_rules = id(new HeraldRuleQuery()) 653 ->setViewer($viewer) 654 ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_GLOBAL)) 655 ->withContentTypes(array($rule->getContentType())) 656 ->execute(); 657 658 if ($rule->isObjectRule()) { 659 // Object rules may depend on other rules for the same object. 660 $all_rules += id(new HeraldRuleQuery()) 661 ->setViewer($viewer) 662 ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_OBJECT)) 663 ->withContentTypes(array($rule->getContentType())) 664 ->withTriggerObjectPHIDs(array($rule->getTriggerObjectPHID())) 665 ->execute(); 666 } 667 668 if ($rule->isPersonalRule()) { 669 // Personal rules may depend upon your other personal rules. 670 $all_rules += id(new HeraldRuleQuery()) 671 ->setViewer($viewer) 672 ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_PERSONAL)) 673 ->withContentTypes(array($rule->getContentType())) 674 ->withAuthorPHIDs(array($rule->getAuthorPHID())) 675 ->execute(); 676 } 677 678 // A rule can not depend upon itself. 679 unset($all_rules[$rule->getID()]); 680 681 return $all_rules; 682 } 683 684 private function getFieldGroups(HeraldAdapter $adapter, array $field_map) { 685 $group_map = array(); 686 foreach ($field_map as $field_key => $field_name) { 687 $group_key = $adapter->getFieldGroupKey($field_key); 688 $group_map[$group_key][$field_key] = array( 689 'name' => $field_name, 690 'available' => $adapter->isFieldAvailable($field_key), 691 ); 692 } 693 694 return $this->getGroups( 695 $group_map, 696 HeraldFieldGroup::getAllFieldGroups()); 697 } 698 699 private function getActionGroups(HeraldAdapter $adapter, array $action_map) { 700 $group_map = array(); 701 foreach ($action_map as $action_key => $action_name) { 702 $group_key = $adapter->getActionGroupKey($action_key); 703 $group_map[$group_key][$action_key] = array( 704 'name' => $action_name, 705 'available' => $adapter->isActionAvailable($action_key), 706 ); 707 } 708 709 return $this->getGroups( 710 $group_map, 711 HeraldActionGroup::getAllActionGroups()); 712 } 713 714 /** 715 * @param array $item_map 716 * @param array<HeraldGroup> $group_list 717 */ 718 private function getGroups(array $item_map, array $group_list) { 719 assert_instances_of($group_list, HeraldGroup::class); 720 721 $groups = array(); 722 foreach ($item_map as $group_key => $options) { 723 asort($options); 724 725 $group_object = idx($group_list, $group_key); 726 if ($group_object) { 727 $group_label = $group_object->getGroupLabel(); 728 $group_order = $group_object->getSortKey(); 729 } else { 730 $group_label = nonempty($group_key, pht('Other')); 731 $group_order = 'Z'; 732 } 733 734 $groups[] = array( 735 'label' => $group_label, 736 'options' => $options, 737 'order' => $group_order, 738 ); 739 } 740 741 return array_values(isort($groups, 'order')); 742 } 743 744 745}