diff --git a/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php b/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php index 077fc511ce..60a41a26ca 100644 --- a/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php +++ b/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php @@ -1,503 +1,530 @@ array( 'name' => pht('Unbreak Now!'), 'keywords' => array('unbreak'), 'short' => pht('Unbreak!'), 'color' => 'pink', ), 90 => array( 'name' => pht('Needs Triage'), 'keywords' => array('triage'), 'short' => pht('Triage'), 'color' => 'violet', ), 80 => array( 'name' => pht('High'), 'keywords' => array('high'), 'short' => pht('High'), 'color' => 'red', ), 50 => array( 'name' => pht('Normal'), 'keywords' => array('normal'), 'short' => pht('Normal'), 'color' => 'orange', ), 25 => array( 'name' => pht('Low'), 'keywords' => array('low'), 'short' => pht('Low'), 'color' => 'yellow', ), 0 => array( 'name' => pht('Wishlist'), 'keywords' => array('wish', 'wishlist'), 'short' => pht('Wish'), 'color' => 'sky', ), ); $status_type = 'maniphest.statuses'; $status_defaults = array( 'open' => array( 'name' => pht('Open'), 'special' => ManiphestTaskStatus::SPECIAL_DEFAULT, 'prefixes' => array( 'open', 'opens', 'reopen', 'reopens', ), ), 'resolved' => array( 'name' => pht('Resolved'), 'name.full' => pht('Closed, Resolved'), 'closed' => true, 'special' => ManiphestTaskStatus::SPECIAL_CLOSED, 'transaction.icon' => 'fa-check-circle', 'prefixes' => array( 'closed', 'closes', 'close', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved', ), 'suffixes' => array( 'as resolved', 'as fixed', ), 'keywords' => array('closed', 'fixed', 'resolved'), ), 'wontfix' => array( 'name' => pht('Wontfix'), 'name.full' => pht('Closed, Wontfix'), 'transaction.icon' => 'fa-ban', 'closed' => true, 'prefixes' => array( 'wontfix', 'wontfixes', 'wontfixed', ), 'suffixes' => array( 'as wontfix', ), ), 'invalid' => array( 'name' => pht('Invalid'), 'name.full' => pht('Closed, Invalid'), 'transaction.icon' => 'fa-minus-circle', 'closed' => true, 'claim' => false, 'prefixes' => array( 'invalidate', 'invalidates', 'invalidated', ), 'suffixes' => array( 'as invalid', ), ), 'duplicate' => array( 'name' => pht('Duplicate'), 'name.full' => pht('Closed, Duplicate'), 'transaction.icon' => 'fa-files-o', 'special' => ManiphestTaskStatus::SPECIAL_DUPLICATE, 'closed' => true, 'claim' => false, ), 'spite' => array( 'name' => pht('Spite'), 'name.full' => pht('Closed, Spite'), 'name.action' => pht('Spited'), 'transaction.icon' => 'fa-thumbs-o-down', 'silly' => true, 'closed' => true, 'prefixes' => array( 'spite', 'spites', 'spited', ), 'suffixes' => array( 'out of spite', 'as spite', ), ), ); $status_description = $this->deformat(pht(<<.// Allows you to specify a list of text prefixes which will trigger a task transition into this status when mentioned in a commit message. For example, providing "closes" here will allow users to move tasks to this status by writing `Closes T123` in commit messages. - `suffixes` //Optional list.// Allows you to specify a list of text suffixes which will trigger a task transition into this status when mentioned in a commit message, after a valid prefix. For example, providing "as invalid" here will allow users to move tasks to this status by writing `Closes T123 as invalid`, even if another status is selected by the "Closes" prefix. - `keywords` //Optional list.// Allows you to specify a list of keywords which can be used with `!status` commands in email to select this status. - `disabled` //Optional bool.// Marks this status as no longer in use so tasks can not be created or edited to have this status. Existing tasks with this status will not be affected, but you can batch edit them or let them die out on their own. - `claim` //Optional bool.// By default, closing an unassigned task claims it. You can set this to `false` to disable this behavior for a particular status. - `locked` //Optional string.// Lock tasks in this status. Specify "comments" to lock comments (users who can edit the task may override this lock). Specify "edits" to prevent anyone except the task owner from making edits. - `mfa` //Optional bool.// Require all edits to this task to be signed with multi-factor authentication. Statuses will appear in the UI in the order specified. Note the status marked `special` as `duplicate` is not settable directly and will not appear in UI elements, and that any status marked `silly` does not appear if Phabricator is configured with `phabricator.serious-business` set to true. Examining the default configuration and examples below will probably be helpful in understanding these options. EOTEXT )); $status_example = array( 'open' => array( 'name' => pht('Open'), 'special' => 'default', ), 'closed' => array( 'name' => pht('Closed'), 'special' => 'closed', 'closed' => true, ), 'duplicate' => array( 'name' => pht('Duplicate'), 'special' => 'duplicate', 'closed' => true, ), ); $json = new PhutilJSON(); $status_example = $json->encodeFormatted($status_example); // This is intentionally blank for now, until we can move more Maniphest // logic to custom fields. $default_fields = array(); foreach ($default_fields as $key => $enabled) { $default_fields[$key] = array( 'disabled' => !$enabled, ); } $custom_field_type = 'custom:PhabricatorCustomFieldConfigOptionType'; $fields_example = array( 'mycompany.estimated-hours' => array( 'name' => pht('Estimated Hours'), 'type' => 'int', 'caption' => pht('Estimated number of hours this will take.'), ), ); $fields_json = id(new PhutilJSON())->encodeFormatted($fields_example); $points_type = 'maniphest.points'; $points_example_1 = array( 'enabled' => true, 'label' => pht('Story Points'), 'action' => pht('Change Story Points'), ); $points_json_1 = id(new PhutilJSON())->encodeFormatted($points_example_1); $points_example_2 = array( 'enabled' => true, 'label' => pht('Estimated Hours'), 'action' => pht('Change Estimate'), ); $points_json_2 = id(new PhutilJSON())->encodeFormatted($points_example_2); $points_description = $this->deformat(pht(<< $subtype_default_key, 'name' => pht('Task'), ), array( 'key' => 'bug', 'name' => pht('Bug'), ), array( 'key' => 'feature', 'name' => pht('Feature Request'), ), ); $subtype_example = id(new PhutilJSON())->encodeAsList($subtype_example); $subtype_default = array( array( 'key' => $subtype_default_key, 'name' => pht('Task'), ), ); $subtype_description = $this->deformat(pht(<<.// Show users creation forms for these task subtypes. - `forms`: //Optional list.// Show users these specific forms, in order. If you don't specify either constraint, users will be shown creation forms for the same subtype. For example, if you have a "quest" subtype and do not configure `children`, users who click "Create Subtask" will be presented with all create forms for "quest" tasks. If you want to present them with forms for a different task subtype or set of subtypes instead, use `subtypes`: ``` { ... "children": { "subtypes": ["objective", "boss", "reward"] } ... } ``` If you want to present them with specific forms, use `forms` and specify form IDs: ``` { ... "children": { "forms": [12, 16] } ... } ``` When specifying forms by ID explicitly, the order you specify the forms in will be used when presenting options to the user. If only one option would be presented, the user will be taken directly to the appropriate form instead of being prompted to choose a form. The `fields` key can configure the behavior of custom fields on specific task subtypes. For example: ``` -{ - ... - "fields": { - "custom.some-field": { - "disabled": true + { + ... + "fields": { + "custom.some-field": { + "disabled": true + } } + ... } - ... -} ``` Each field supports these options: - `disabled` //Optional bool.// Allows you to disable fields on certain subtypes. - `name` //Optional string.// Custom name of this field for the subtype. + +The `mutations` key allows you to control the behavior of the "Change Subtype" +action above the comment area. By default, this action allows users to change +the task subtype into any other subtype. + +If you'd prefer to make it more difficult to change subtypes or offer only a +subset of subtypes, you can specify the list of subtypes that "Change Subtypes" +offers. For example, if you have several similar subtypes and want to allow +tasks to be converted between them but not easily converted to other types, +you can make the "Change Subtypes" control show only these options like this: + +``` + { + ... + "mutations": ["bug", "issue", "defect"] + ... + } +``` + +If you specify an empty list, the "Change Subtypes" action will be completely +hidden. + +This mutation list is advisory and only configures the UI. Tasks may still be +converted across subtypes freely by using the Bulk Editor or API. + EOTEXT , $subtype_default_key)); $priorities_description = $this->deformat(pht(<<.// List of unique keywords which identify this priority, like "high" or "low". Each priority must have at least one keyword and two priorities may not share the same keyword. - `short` //Optional string.// Alternate shorter name, used in UIs where there is less space available. - `color` //Optional string.// Color for this priority, like "red" or "blue". - `disabled` //Optional bool.// Set to true to prevent users from choosing this priority when creating or editing tasks. Existing tasks will not be affected, and can be batch edited to a different priority or left to eventually die out. You can choose the default priority for newly created tasks with "maniphest.default-priority". EOTEXT )); $fields_description = $this->deformat(pht(<<newOption('maniphest.custom-field-definitions', 'wild', array()) ->setSummary(pht('Custom Maniphest fields.')) ->setDescription($fields_description) ->addExample($fields_json, pht('Valid setting')), $this->newOption('maniphest.fields', $custom_field_type, $default_fields) ->setCustomData(id(new ManiphestTask())->getCustomFieldBaseClass()) ->setDescription(pht('Select and reorder task fields.')), $this->newOption( 'maniphest.priorities', $priority_type, $priority_defaults) ->setSummary(pht('Configure Maniphest priority names.')) ->setDescription($priorities_description), $this->newOption('maniphest.statuses', $status_type, $status_defaults) ->setSummary(pht('Configure Maniphest task statuses.')) ->setDescription($status_description) ->addExample($status_example, pht('Minimal Valid Config')), $this->newOption('maniphest.default-priority', 'int', 90) ->setSummary(pht('Default task priority for create flows.')) ->setDescription( pht( 'Choose a default priority for newly created tasks. You can '. 'review and adjust available priorities by using the '. '%s configuration option. The default value (`90`) '. 'corresponds to the default "Needs Triage" priority.', 'maniphest.priorities')), $this->newOption('maniphest.points', $points_type, array()) ->setSummary(pht('Configure point values for tasks.')) ->setDescription($points_description) ->addExample($points_json_1, pht('Points Config')) ->addExample($points_json_2, pht('Hours Config')), $this->newOption('maniphest.subtypes', $subtype_type, $subtype_default) ->setSummary(pht('Define task subtypes.')) ->setDescription($subtype_description) ->addExample($subtype_example, pht('Simple Subtypes')), ); } } diff --git a/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php b/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php index 6e1d1de115..f471fcd92f 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php @@ -1,281 +1,316 @@ key = $key; return $this; } public function getKey() { return $this->key; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setIcon($icon) { $this->icon = $icon; return $this; } public function getIcon() { return $this->icon; } public function setTagText($text) { $this->tagText = $text; return $this; } public function getTagText() { return $this->tagText; } public function setColor($color) { $this->color = $color; return $this; } public function getColor() { return $this->color; } public function setChildSubtypes(array $child_subtypes) { $this->childSubtypes = $child_subtypes; return $this; } public function getChildSubtypes() { return $this->childSubtypes; } public function setChildFormIdentifiers(array $child_identifiers) { $this->childIdentifiers = $child_identifiers; return $this; } public function getChildFormIdentifiers() { return $this->childIdentifiers; } + public function setMutations($mutations) { + $this->mutations = $mutations; + return $this; + } + + public function getMutations() { + return $this->mutations; + } + public function hasTagView() { return (bool)strlen($this->getTagText()); } public function newTagView() { $view = id(new PHUITagView()) ->setType(PHUITagView::TYPE_OUTLINE) ->setName($this->getTagText()); $color = $this->getColor(); if ($color) { $view->setColor($color); } return $view; } public function setSubtypeFieldConfiguration( $subtype_key, array $configuration) { $this->fieldConfiguration[$subtype_key] = $configuration; return $this; } public function getSubtypeFieldConfiguration($subtype_key) { return idx($this->fieldConfiguration, $subtype_key); } public static function validateSubtypeKey($subtype) { if (strlen($subtype) > 64) { throw new Exception( pht( 'Subtype "%s" is not valid: subtype keys must be no longer than '. '64 bytes.', $subtype)); } if (strlen($subtype) < 3) { throw new Exception( pht( 'Subtype "%s" is not valid: subtype keys must have a minimum '. 'length of 3 bytes.', $subtype)); } if (!preg_match('/^[a-z]+\z/', $subtype)) { throw new Exception( pht( 'Subtype "%s" is not valid: subtype keys may only contain '. 'lowercase latin letters ("a" through "z").', $subtype)); } } public static function validateConfiguration($config) { if (!is_array($config)) { throw new Exception( pht( 'Subtype configuration is invalid: it must be a list of subtype '. 'specifications.')); } $map = array(); foreach ($config as $value) { PhutilTypeSpec::checkMap( $value, array( 'key' => 'string', 'name' => 'string', 'tag' => 'optional string', 'color' => 'optional string', 'icon' => 'optional string', 'children' => 'optional map', 'fields' => 'optional map', + 'mutations' => 'optional list', )); $key = $value['key']; self::validateSubtypeKey($key); if (isset($map[$key])) { throw new Exception( pht( 'Subtype configuration is invalid: two subtypes use the same '. 'key ("%s"). Each subtype must have a unique key.', $key)); } $map[$key] = true; $name = $value['name']; if (!strlen($name)) { throw new Exception( pht( 'Subtype configuration is invalid: subtype with key "%s" has '. 'no name. Subtypes must have a name.', $key)); } $children = idx($value, 'children'); if ($children) { PhutilTypeSpec::checkMap( $children, array( 'subtypes' => 'optional list', 'forms' => 'optional list', )); $child_subtypes = idx($children, 'subtypes'); $child_forms = idx($children, 'forms'); if ($child_subtypes && $child_forms) { throw new Exception( pht( 'Subtype configuration is invalid: subtype with key "%s" '. 'specifies both child subtypes and child forms. Specify one '. 'or the other, but not both.')); } } $fields = idx($value, 'fields'); if ($fields) { foreach ($fields as $field_key => $configuration) { PhutilTypeSpec::checkMap( $configuration, array( 'disabled' => 'optional bool', 'name' => 'optional string', )); } } } if (!isset($map[self::SUBTYPE_DEFAULT])) { throw new Exception( pht( 'Subtype configuration is invalid: there is no subtype defined '. 'with key "%s". This subtype is required and must be defined.', self::SUBTYPE_DEFAULT)); } + + foreach ($config as $value) { + $key = idx($value, 'key'); + + $mutations = idx($value, 'mutations'); + if (!$mutations) { + continue; + } + + foreach ($mutations as $mutation) { + if (!isset($map[$mutation])) { + throw new Exception( + pht( + 'Subtype configuration is invalid: subtype with key "%s" '. + 'specifies that it can mutate into subtype "%s", but that is '. + 'not a valid subtype.', + $key, + $mutation)); + } + } + } + } public static function newSubtypeMap(array $config) { $map = array(); foreach ($config as $entry) { $key = $entry['key']; $name = $entry['name']; $tag_text = idx($entry, 'tag'); if ($tag_text === null) { if ($key != self::SUBTYPE_DEFAULT) { $tag_text = phutil_utf8_strtoupper($name); } } $color = idx($entry, 'color', 'blue'); $icon = idx($entry, 'icon', 'fa-drivers-license-o'); $subtype = id(new self()) ->setKey($key) ->setName($name) ->setTagText($tag_text) ->setIcon($icon); if ($color) { $subtype->setColor($color); } $children = idx($entry, 'children', array()); $child_subtypes = idx($children, 'subtypes'); $child_forms = idx($children, 'forms'); if ($child_subtypes) { $subtype->setChildSubtypes($child_subtypes); } if ($child_forms) { $subtype->setChildFormIdentifiers($child_forms); } $field_configurations = idx($entry, 'fields'); if ($field_configurations) { foreach ($field_configurations as $field_key => $field_configuration) { $subtype->setSubtypeFieldConfiguration( $field_key, $field_configuration); } } + $subtype->setMutations(idx($entry, 'mutations')); + $map[$key] = $subtype; } return new PhabricatorEditEngineSubtypeMap($map); } public function newIconView() { return id(new PHUIIconView()) ->setIcon($this->getIcon(), $this->getColor()); } } diff --git a/src/applications/transactions/editengine/PhabricatorEditEngineSubtypeMap.php b/src/applications/transactions/editengine/PhabricatorEditEngineSubtypeMap.php index dc1ee2842a..edf2d2045a 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngineSubtypeMap.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngineSubtypeMap.php @@ -1,97 +1,135 @@ subtypes = $subtypes; } public function getDisplayMap() { return mpull($this->subtypes, 'getName'); } public function getCount() { return count($this->subtypes); } public function isValidSubtype($subtype_key) { return isset($this->subtypes[$subtype_key]); } public function getSubtypes() { return $this->subtypes; } public function getSubtype($subtype_key) { if (!$this->isValidSubtype($subtype_key)) { throw new Exception( pht( 'Subtype key "%s" does not identify a valid subtype.', $subtype_key)); } return $this->subtypes[$subtype_key]; } public function setDatasource(PhabricatorTypeaheadDatasource $datasource) { $this->datasource = $datasource; return $this; } public function newDatasource() { if (!$this->datasource) { throw new PhutilInvalidStateException('setDatasource'); } return clone($this->datasource); } + public function getMutationMap($source_key) { + return mpull($this->getMutations($source_key), 'getName'); + } + + public function getMutations($source_key) { + $mutations = $this->subtypes; + + $subtype = idx($this->subtypes, $source_key); + if ($subtype) { + $map = $subtype->getMutations(); + if ($map !== null) { + $map = array_fuse($map); + foreach ($mutations as $key => $mutation) { + if ($key === $source_key) { + // This is the current subtype, so we always want to show it. + continue; + } + + if (isset($map[$key])) { + // This is an allowed mutation, so keep it. + continue; + } + + // Discard other subtypes as mutation options. + unset($mutations[$key]); + } + } + } + + // If the only available mutation is the current subtype, treat this like + // no mutations are available. + if (array_keys($mutations) === array($source_key)) { + $mutations = array(); + } + + return $mutations; + } + public function getCreateFormsForSubtype( PhabricatorEditEngine $edit_engine, PhabricatorEditEngineSubtypeInterface $object) { $subtype_key = $object->getEditEngineSubtype(); $subtype = $this->getSubtype($subtype_key); $select_identifiers = $subtype->getChildFormIdentifiers(); $select_subtypes = $subtype->getChildSubtypes(); $query = $edit_engine->newConfigurationQuery() ->withIsDisabled(false); if ($select_identifiers) { $query->withIdentifiers($select_identifiers); } else { // If we're selecting by subtype rather than selecting specific forms, // only select create forms. $query->withIsDefault(true); if ($select_subtypes) { $query->withSubtypes($select_subtypes); } else { $query->withSubtypes(array($subtype_key)); } } $forms = $query->execute(); $forms = mpull($forms, null, 'getIdentifier'); // If we're selecting by ID, respect the order specified in the // constraint. Otherwise, use the create form sort order. if ($select_identifiers) { $forms = array_select_keys($forms, $select_identifiers) + $forms; } else { $forms = msort($forms, 'getCreateSortKey'); } return $forms; } } diff --git a/src/applications/transactions/engineextension/PhabricatorSubtypeEditEngineExtension.php b/src/applications/transactions/engineextension/PhabricatorSubtypeEditEngineExtension.php index d0b6d017f3..e73d476d74 100644 --- a/src/applications/transactions/engineextension/PhabricatorSubtypeEditEngineExtension.php +++ b/src/applications/transactions/engineextension/PhabricatorSubtypeEditEngineExtension.php @@ -1,60 +1,68 @@ supportsSubtypes(); } public function buildCustomEditFields( PhabricatorEditEngine $engine, PhabricatorApplicationTransactionInterface $object) { $subtype_type = PhabricatorTransactions::TYPE_SUBTYPE; + $subtype_value = $object->getEditEngineSubtype(); $map = $object->newEditEngineSubtypeMap(); - $options = $map->getDisplayMap(); + + if ($object->getID()) { + $options = $map->getMutationMap($subtype_value); + } else { + // NOTE: This is a crude proxy for "are we in the bulk edit workflow". + // We want to allow any mutation. + $options = $map->getDisplayMap(); + } $subtype_field = id(new PhabricatorSelectEditField()) ->setKey(self::EDITKEY) ->setLabel(pht('Subtype')) ->setIsFormField(false) ->setTransactionType($subtype_type) ->setConduitDescription(pht('Change the object subtype.')) ->setConduitTypeDescription(pht('New object subtype key.')) - ->setValue($object->getEditEngineSubtype()) + ->setValue($subtype_value) ->setOptions($options); - // If subtypes are configured, enable changing them from the bulk editor - // and comment action stack. - if ($map->getCount() > 1) { + // If subtypes are configured, enable changing them from the bulk editor. + // Bulk editor + if ($options) { $subtype_field ->setBulkEditLabel(pht('Change subtype to')) ->setCommentActionLabel(pht('Change Subtype')) ->setCommentActionOrder(3000); } return array( $subtype_field, ); } }