diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php index d1966cf106..2750adc2ea 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php @@ -1,168 +1,212 @@ getRequest(); $viewer = $request->getViewer(); $id = $request->getURIData('id'); $trigger = id(new PhabricatorProjectTriggerQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->executeOne(); if (!$trigger) { return new Aphront404Response(); } + $rules_view = $this->newRulesView($trigger); $columns_view = $this->newColumnsView($trigger); $title = $trigger->getObjectName(); $header = id(new PHUIHeaderView()) ->setHeader($trigger->getDisplayName()); $timeline = $this->buildTransactionTimeline( $trigger, new PhabricatorProjectTriggerTransactionQuery()); $timeline->setShouldTerminate(true); $curtain = $this->newCurtain($trigger); $column_view = id(new PHUITwoColumnView()) ->setHeader($header) ->setCurtain($curtain) ->setMainColumn( array( + $rules_view, $columns_view, $timeline, )); $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb($trigger->getObjectName()) ->setBorder(true); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($column_view); } private function newColumnsView(PhabricatorProjectTrigger $trigger) { $viewer = $this->getViewer(); // NOTE: When showing columns which use this trigger, we want to represent // all columns the trigger is used by: even columns the user can't see. // If we hide columns the viewer can't see, they might think that the // trigger isn't widely used and is safe to edit, when it may actually // be in use on workboards they don't have access to. // Query the columns with the omnipotent viewer first, then pull out their // PHIDs and throw the actual objects away. Re-query with the real viewer // so we load only the columns they can actually see, but have a list of // all the impacted column PHIDs. $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $all_columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($omnipotent_viewer) ->withTriggerPHIDs(array($trigger->getPHID())) ->execute(); $column_phids = mpull($all_columns, 'getPHID'); if ($column_phids) { $visible_columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withPHIDs($column_phids) ->execute(); $visible_columns = mpull($visible_columns, null, 'getPHID'); } else { $visible_columns = array(); } $rows = array(); foreach ($column_phids as $column_phid) { $column = idx($visible_columns, $column_phid); if ($column) { $project = $column->getProject(); $project_name = phutil_tag( 'a', array( 'href' => $project->getURI(), ), $project->getDisplayName()); $column_name = phutil_tag( 'a', array( 'href' => $column->getBoardURI(), ), $column->getDisplayName()); } else { $project_name = null; $column_name = phutil_tag('em', array(), pht('Restricted Column')); } $rows[] = array( $project_name, $column_name, ); } $table_view = id(new AphrontTableView($rows)) ->setNoDataString(pht('This trigger is not used by any columns.')) ->setHeaders( array( pht('Project'), pht('Column'), )) ->setColumnClasses( array( null, 'wide pri', )); $header_view = id(new PHUIHeaderView()) ->setHeader(pht('Used by Columns')); return id(new PHUIObjectBoxView()) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setHeader($header_view) ->setTable($table_view); } + private function newRulesView(PhabricatorProjectTrigger $trigger) { + $viewer = $this->getViewer(); + $rules = $trigger->getTriggerRules(); + + $rows = array(); + foreach ($rules as $rule) { + $value = $rule->getRecord()->getValue(); + + $rows[] = array( + $rule->getRuleViewIcon($value), + $rule->getRuleViewLabel(), + $rule->getRuleViewDescription($value), + ); + } + + $table_view = id(new AphrontTableView($rows)) + ->setNoDataString(pht('This trigger has no rules.')) + ->setHeaders( + array( + null, + pht('Rule'), + pht('Action'), + )) + ->setColumnClasses( + array( + null, + 'pri', + 'wide', + )); + + $header_view = id(new PHUIHeaderView()) + ->setHeader(pht('Trigger Rules')) + ->setSubheader( + pht( + 'When a card is dropped into a column that uses this trigger, '. + 'these actions will be taken.')); + + return id(new PHUIObjectBoxView()) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setHeader($header_view) + ->setTable($table_view); + } private function newCurtain(PhabricatorProjectTrigger $trigger) { $viewer = $this->getViewer(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $trigger, PhabricatorPolicyCapability::CAN_EDIT); $curtain = $this->newCurtainView($trigger); $edit_uri = $this->getApplicationURI( urisprintf( 'trigger/edit/%d/', $trigger->getID())); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Trigger')) ->setIcon('fa-pencil') ->setHref($edit_uri) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); return $curtain; } } diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php index a195e9fc44..5029c2caea 100644 --- a/src/applications/project/storage/PhabricatorProjectTrigger.php +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -1,326 +1,327 @@ setName('') ->setEditPolicy($default_edit); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'ruleset' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text255', ), self::CONFIG_KEY_SCHEMA => array( ), ) + parent::getConfiguration(); } public function getPHIDType() { return PhabricatorProjectTriggerPHIDType::TYPECONST; } public function getDisplayName() { $name = $this->getName(); if (strlen($name)) { return $name; } return $this->getDefaultName(); } public function getDefaultName() { return pht('Custom Trigger'); } public function getURI() { return urisprintf( '/project/trigger/%d/', $this->getID()); } public function getObjectName() { return pht('Trigger %d', $this->getID()); } public function setRuleset(array $ruleset) { // Clear any cached trigger rules, since we're changing the ruleset // for the trigger. $this->triggerRules = null; parent::setRuleset($ruleset); } public function getTriggerRules() { if ($this->triggerRules === null) { $trigger_rules = self::newTriggerRulesFromRuleSpecifications( $this->getRuleset(), $allow_invalid = true); $this->triggerRules = $trigger_rules; } return $this->triggerRules; } public static function newTriggerRulesFromRuleSpecifications( array $list, $allow_invalid) { // NOTE: With "$allow_invalid" set, we're trying to preserve the database // state in the rule structure, even if it includes rule types we don't // ha ve implementations for, or rules with invalid rule values. // If an administrator adds or removes extensions which add rules, or // an upgrade affects rule validity, existing rules may become invalid. // When they do, we still want the UI to reflect the ruleset state // accurately and "Edit" + "Save" shouldn't destroy data unless the // user explicitly modifies the ruleset. // In this mode, when we run into rules which are structured correctly but // which have types we don't know about, we replace them with "Unknown // Rules". If we know about the type of a rule but the value doesn't // validate, we replace it with "Invalid Rules". These two rule types don't // take any actions when a card is dropped into the column, but they show // the user what's wrong with the ruleset and can be saved without causing // any collateral damage. $rule_map = PhabricatorProjectTriggerRule::getAllTriggerRules(); // If the stored rule data isn't a list of rules (or we encounter other // fundamental structural problems, below), there isn't much we can do // to try to represent the state. if (!is_array($list)) { throw new PhabricatorProjectTriggerCorruptionException( pht( 'Trigger ruleset is corrupt: expected a list of rule '. 'specifications, found "%s".', phutil_describe_type($list))); } $trigger_rules = array(); foreach ($list as $key => $rule) { if (!is_array($rule)) { throw new PhabricatorProjectTriggerCorruptionException( pht( 'Trigger ruleset is corrupt: rule (with key "%s") should be a '. 'rule specification, but is actually "%s".', $key, phutil_describe_type($rule))); } try { PhutilTypeSpec::checkMap( $rule, array( 'type' => 'string', 'value' => 'wild', )); } catch (PhutilTypeCheckException $ex) { throw new PhabricatorProjectTriggerCorruptionException( pht( 'Trigger ruleset is corrupt: rule (with key "%s") is not a '. 'valid rule specification: %s', $key, $ex->getMessage())); } $record = id(new PhabricatorProjectTriggerRuleRecord()) ->setType(idx($rule, 'type')) ->setValue(idx($rule, 'value')); if (!isset($rule_map[$record->getType()])) { if (!$allow_invalid) { throw new PhabricatorProjectTriggerCorruptionException( pht( 'Trigger ruleset is corrupt: rule type "%s" is unknown.', $record->getType())); } $rule = new PhabricatorProjectTriggerUnknownRule(); } else { $rule = clone $rule_map[$record->getType()]; } try { $rule->setRecord($record); } catch (Exception $ex) { if (!$allow_invalid) { throw new PhabricatorProjectTriggerCorruptionException( pht( 'Trigger ruleset is corrupt, rule (of type "%s") does not '. 'validate: %s', $record->getType(), $ex->getMessage())); } $rule = id(new PhabricatorProjectTriggerInvalidRule()) - ->setRecord($record); + ->setRecord($record) + ->setException($ex); } $trigger_rules[] = $rule; } return $trigger_rules; } public function getDropEffects() { $effects = array(); $rules = $this->getTriggerRules(); foreach ($rules as $rule) { foreach ($rule->getDropEffects() as $effect) { $effects[] = $effect; } } return $effects; } public function getRulesDescription() { $rules = $this->getTriggerRules(); if (!$rules) { return pht('Does nothing.'); } $things = array(); $count = count($rules); $limit = 3; if ($count > $limit) { $show_rules = array_slice($rules, 0, ($limit - 1)); } else { $show_rules = $rules; } foreach ($show_rules as $rule) { $things[] = $rule->getDescription(); } if ($count > $limit) { $things[] = pht( '(Applies %s more actions.)', new PhutilNumber($count - $limit)); } return implode("\n", $things); } public function newDropTransactions( PhabricatorUser $viewer, PhabricatorProjectColumn $column, $object) { $trigger_xactions = array(); foreach ($this->getTriggerRules() as $rule) { $rule ->setViewer($viewer) ->setTrigger($this) ->setColumn($column) ->setObject($object); $xactions = $rule->getDropTransactions( $object, $rule->getRecord()->getValue()); if (!is_array($xactions)) { throw new Exception( pht( 'Expected trigger rule (of class "%s") to return a list of '. 'transactions from "newDropTransactions()", but got "%s".', get_class($rule), phutil_describe_type($xactions))); } $expect_type = get_class($object->getApplicationTransactionTemplate()); assert_instances_of($xactions, $expect_type); foreach ($xactions as $xaction) { $trigger_xactions[] = $xaction; } } return $trigger_xactions; } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorProjectTriggerEditor(); } public function getApplicationTransactionTemplate() { return new PhabricatorProjectTriggerTransaction(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $conn = $this->establishConnection('w'); // Remove the reference to this trigger from any columns which use it. queryfx( $conn, 'UPDATE %R SET triggerPHID = null WHERE triggerPHID = %s', new PhabricatorProjectColumn(), $this->getPHID()); $this->delete(); $this->saveTransaction(); } } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php index 0f9fe52abb..184d818aa5 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php @@ -1,62 +1,99 @@ exception = $exception; + return $this; + } + + public function getException() { + return $this->exception; + } + public function getDescription() { return pht( 'Invalid rule (of type "%s").', $this->getRecord()->getType()); } public function getSelectControlName() { return pht('(Invalid Rule)'); } protected function isSelectableRule() { return false; } protected function assertValidRuleValue($value) { return; } protected function newDropTransactions($object, $value) { return array(); } protected function newDropEffects($value) { return array(); } protected function isValidRule() { return false; } protected function newInvalidView() { return array( id(new PHUIIconView()) ->setIcon('fa-exclamation-triangle red'), ' ', pht( 'This is a trigger rule with a valid type ("%s") but an invalid '. 'value.', $this->getRecord()->getType()), ); } protected function getDefaultValue() { return null; } protected function getPHUIXControlType() { return null; } protected function getPHUIXControlSpecification() { return null; } + public function getRuleViewLabel() { + return pht('Invalid Rule'); + } + + public function getRuleViewDescription($value) { + $record = $this->getRecord(); + $type = $record->getType(); + + $exception = $this->getException(); + if ($exception) { + return pht( + 'This rule (of type "%s") is invalid: %s', + $type, + $exception->getMessage()); + } else { + return pht( + 'This rule (of type "%s") is invalid.', + $type); + } + } + + public function getRuleViewIcon($value) { + return id(new PHUIIconView()) + ->setIcon('fa-exclamation-triangle', 'red'); + } + } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php index 2c40563884..5b1ad2db36 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php @@ -1,80 +1,101 @@ getValue(); return pht( 'Changes status to "%s".', ManiphestTaskStatus::getTaskStatusName($value)); } public function getSelectControlName() { return pht('Change status to'); } protected function assertValidRuleValue($value) { if (!is_string($value)) { throw new Exception( pht( 'Status rule value should be a string, but is not (value is "%s").', phutil_describe_type($value))); } $map = ManiphestTaskStatus::getTaskStatusMap(); if (!isset($map[$value])) { throw new Exception( pht( 'Rule value ("%s") is not a valid task status.', $value)); } } protected function newDropTransactions($object, $value) { return array( $this->newTransaction() ->setTransactionType(ManiphestTaskStatusTransaction::TRANSACTIONTYPE) ->setNewValue($value), ); } protected function newDropEffects($value) { $status_name = ManiphestTaskStatus::getTaskStatusName($value); $status_icon = ManiphestTaskStatus::getStatusIcon($value); $status_color = ManiphestTaskStatus::getStatusColor($value); $content = pht( 'Change status to %s.', phutil_tag('strong', array(), $status_name)); return array( $this->newEffect() ->setIcon($status_icon) ->setColor($status_color) ->addCondition('status', '!=', $value) ->setContent($content), ); } protected function getDefaultValue() { return head_key(ManiphestTaskStatus::getTaskStatusMap()); } protected function getPHUIXControlType() { return 'select'; } protected function getPHUIXControlSpecification() { $map = ManiphestTaskStatus::getTaskStatusMap(); return array( 'options' => $map, 'order' => array_keys($map), ); } + public function getRuleViewLabel() { + return pht('Change Status'); + } + + public function getRuleViewDescription($value) { + $status_name = ManiphestTaskStatus::getTaskStatusName($value); + + return pht( + 'Change task status to %s.', + phutil_tag('strong', array(), $status_name)); + } + + public function getRuleViewIcon($value) { + $status_icon = ManiphestTaskStatus::getStatusIcon($value); + $status_color = ManiphestTaskStatus::getStatusColor($value); + + return id(new PHUIIconView()) + ->setIcon($status_icon, $status_color); + } + + } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php index 9634086235..49fdbf8a93 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -1,146 +1,149 @@ getPhobjectClassConstant('TRIGGERTYPE', 64); } final public static function getAllTriggerRules() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getTriggerType') ->execute(); } final public function setRecord(PhabricatorProjectTriggerRuleRecord $record) { $value = $record->getValue(); $this->assertValidRuleValue($value); $this->record = $record; return $this; } final public function getRecord() { return $this->record; } final protected function getValue() { return $this->getRecord()->getValue(); } abstract public function getDescription(); abstract public function getSelectControlName(); + abstract public function getRuleViewLabel(); + abstract public function getRuleViewDescription($value); + abstract public function getRuleViewIcon($value); abstract protected function assertValidRuleValue($value); abstract protected function newDropTransactions($object, $value); abstract protected function newDropEffects($value); abstract protected function getDefaultValue(); abstract protected function getPHUIXControlType(); abstract protected function getPHUIXControlSpecification(); protected function isSelectableRule() { return true; } protected function isValidRule() { return true; } protected function newInvalidView() { return null; } final public function getDropTransactions($object, $value) { return $this->newDropTransactions($object, $value); } final public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } final public function getViewer() { return $this->viewer; } final public function setColumn(PhabricatorProjectColumn $column) { $this->column = $column; return $this; } final public function getColumn() { return $this->column; } final public function setTrigger(PhabricatorProjectTrigger $trigger) { $this->trigger = $trigger; return $this; } final public function getTrigger() { return $this->trigger; } final public function setObject( PhabricatorApplicationTransactionInterface $object) { $this->object = $object; return $this; } final public function getObject() { return $this->object; } final protected function newTransaction() { return $this->getObject()->getApplicationTransactionTemplate(); } final public function getDropEffects() { return $this->newDropEffects($this->getValue()); } final protected function newEffect() { return new PhabricatorProjectDropEffect(); } final public function toDictionary() { $record = $this->getRecord(); $is_valid = $this->isValidRule(); if (!$is_valid) { $invalid_view = hsprintf('%s', $this->newInvalidView()); } else { $invalid_view = null; } return array( 'type' => $record->getType(), 'value' => $record->getValue(), 'isValidRule' => $is_valid, 'invalidView' => $invalid_view, ); } final public function newTemplate() { return array( 'type' => $this->getTriggerType(), 'name' => $this->getSelectControlName(), 'selectable' => $this->isSelectableRule(), 'defaultValue' => $this->getDefaultValue(), 'control' => array( 'type' => $this->getPHUIXControlType(), 'specification' => $this->getPHUIXControlSpecification(), ), ); } } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php index 008092061d..f71ee44ad7 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php @@ -1,61 +1,77 @@ getRecord()->getType()); } public function getSelectControlName() { return pht('(Unknown Rule)'); } protected function isSelectableRule() { return false; } protected function assertValidRuleValue($value) { return; } protected function newDropTransactions($object, $value) { return array(); } protected function newDropEffects($value) { return array(); } protected function isValidRule() { return false; } protected function newInvalidView() { return array( id(new PHUIIconView()) ->setIcon('fa-exclamation-triangle yellow'), ' ', pht( 'This is a trigger rule with a unknown type ("%s").', $this->getRecord()->getType()), ); } protected function getDefaultValue() { return null; } protected function getPHUIXControlType() { return null; } protected function getPHUIXControlSpecification() { return null; } + public function getRuleViewLabel() { + return pht('Unknown Rule'); + } + + public function getRuleViewDescription($value) { + return pht( + 'This is an unknown rule of type "%s". An administrator may have '. + 'edited or removed an extension which implements this rule type.', + $this->getRecord()->getType()); + } + + public function getRuleViewIcon($value) { + return id(new PHUIIconView()) + ->setIcon('fa-question-circle', 'yellow'); + } + }