diff --git a/src/applications/project/xaction/PhabricatorProjectSlugsTransaction.php b/src/applications/project/xaction/PhabricatorProjectSlugsTransaction.php index d442c39a76..e1f22b3746 100644 --- a/src/applications/project/xaction/PhabricatorProjectSlugsTransaction.php +++ b/src/applications/project/xaction/PhabricatorProjectSlugsTransaction.php @@ -1,164 +1,174 @@ getSlugs(); $slugs = mpull($slugs, 'getSlug', 'getSlug'); unset($slugs[$object->getPrimarySlug()]); return array_keys($slugs); } public function generateNewValue($object, $value) { return $this->getEditor()->normalizeSlugs($value); } public function applyInternalEffects($object, $value) { return; } public function applyExternalEffects($object, $value) { $old = $this->getOldValue(); $new = $value; $add = array_diff($new, $old); $rem = array_diff($old, $new); foreach ($add as $slug) { $this->getEditor()->addSlug($object, $slug, true); } $this->getEditor()->removeSlugs($object, $rem); } public function getTitle() { $old = $this->getOldValue(); $new = $this->getNewValue(); $add = array_diff($new, $old); $rem = array_diff($old, $new); + $add = $this->renderHashtags($add); + $rem = $this->renderHashtags($rem); + if ($add && $rem) { return pht( '%s changed project hashtag(s), added %d: %s; removed %d: %s.', $this->renderAuthor(), count($add), - $this->renderSlugList($add), + $this->renderValueList($add), count($rem), - $this->renderSlugList($rem)); + $this->renderValueList($rem)); } else if ($add) { return pht( '%s added %d project hashtag(s): %s.', $this->renderAuthor(), count($add), - $this->renderSlugList($add)); + $this->renderValueList($add)); } else if ($rem) { return pht( '%s removed %d project hashtag(s): %s.', $this->renderAuthor(), count($rem), - $this->renderSlugList($rem)); + $this->renderValueList($rem)); } } public function getTitleForFeed() { $old = $this->getOldValue(); $new = $this->getNewValue(); $add = array_diff($new, $old); $rem = array_diff($old, $new); + $add = $this->renderHashtags($add); + $rem = $this->renderHashtags($rem); + if ($add && $rem) { return pht( '%s changed %s hashtag(s), added %d: %s; removed %d: %s.', $this->renderAuthor(), $this->renderObject(), count($add), - $this->renderSlugList($add), + $this->renderValueList($add), count($rem), - $this->renderSlugList($rem)); + $this->renderValueList($rem)); } else if ($add) { return pht( '%s added %d %s hashtag(s): %s.', $this->renderAuthor(), count($add), $this->renderObject(), - $this->renderSlugList($add)); + $this->renderValueList($add)); } else if ($rem) { return pht( '%s removed %d %s hashtag(s): %s.', $this->renderAuthor(), count($rem), $this->renderObject(), - $this->renderSlugList($rem)); + $this->renderValueList($rem)); } } public function getIcon() { return 'fa-tag'; } public function validateTransactions($object, array $xactions) { $errors = array(); if (!$xactions) { return $errors; } $slug_xaction = last($xactions); $new = $slug_xaction->getNewValue(); $invalid = array(); foreach ($new as $slug) { if (!PhabricatorSlug::isValidProjectSlug($slug)) { $invalid[] = $slug; } } if ($invalid) { $errors[] = $this->newInvalidError( pht( 'Hashtags must contain at least one letter or number. %s '. 'project hashtag(s) are invalid: %s.', phutil_count($invalid), implode(', ', $invalid))); return $errors; } $new = $this->getEditor()->normalizeSlugs($new); if ($new) { $slugs_used_already = id(new PhabricatorProjectSlug()) ->loadAllWhere('slug IN (%Ls)', $new); } else { // The project doesn't have any extra slugs. $slugs_used_already = array(); } $slugs_used_already = mgroup($slugs_used_already, 'getProjectPHID'); foreach ($slugs_used_already as $project_phid => $used_slugs) { if ($project_phid == $object->getPHID()) { continue; } $used_slug_strs = mpull($used_slugs, 'getSlug'); $errors[] = $this->newInvalidError( pht( '%s project hashtag(s) are already used by other projects: %s.', phutil_count($used_slug_strs), implode(', ', $used_slug_strs))); } return $errors; } - private function renderSlugList($slugs) { - return implode(', ', $slugs); + private function renderHashtags(array $tags) { + $result = array(); + foreach ($tags as $tag) { + $result[] = '#'.$tag; + } + return $result; } } diff --git a/src/applications/transactions/storage/PhabricatorModularTransactionType.php b/src/applications/transactions/storage/PhabricatorModularTransactionType.php index 8a56e8e8ce..128b5c7c19 100644 --- a/src/applications/transactions/storage/PhabricatorModularTransactionType.php +++ b/src/applications/transactions/storage/PhabricatorModularTransactionType.php @@ -1,322 +1,335 @@ getPhobjectClassConstant('TRANSACTIONTYPE'); } public function generateOldValue($object) { throw new PhutilMethodNotImplementedException(); } public function generateNewValue($object, $value) { return $value; } public function validateTransactions($object, array $xactions) { return array(); } public function willApplyTransactions($object, array $xactions) { return; } public function applyInternalEffects($object, $value) { return; } public function applyExternalEffects($object, $value) { return; } public function getTransactionHasEffect($object, $old, $new) { return ($old !== $new); } public function extractFilePHIDs($object, $value) { return array(); } public function shouldHide() { return false; } public function getIcon() { return null; } public function getTitle() { return null; } public function getTitleForFeed() { return null; } public function getActionName() { return null; } public function getActionStrength() { return null; } public function getColor() { return null; } public function hasChangeDetailView() { return false; } public function newChangeDetailView() { return null; } public function getMailDiffSectionHeader() { return pht('EDIT DETAILS'); } public function newRemarkupChanges() { return array(); } public function mergeTransactions( $object, PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { return null; } final public function setStorage( PhabricatorApplicationTransaction $xaction) { $this->storage = $xaction; return $this; } private function getStorage() { return $this->storage; } final public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } final protected function getViewer() { return $this->viewer; } final public function getActor() { return $this->getEditor()->getActor(); } final public function getActingAsPHID() { return $this->getEditor()->getActingAsPHID(); } final public function setEditor( PhabricatorApplicationTransactionEditor $editor) { $this->editor = $editor; return $this; } final protected function getEditor() { if (!$this->editor) { throw new PhutilInvalidStateException('setEditor'); } return $this->editor; } final protected function getAuthorPHID() { return $this->getStorage()->getAuthorPHID(); } final protected function getObjectPHID() { return $this->getStorage()->getObjectPHID(); } final protected function getObject() { return $this->getStorage()->getObject(); } final protected function getOldValue() { return $this->getStorage()->getOldValue(); } final protected function getNewValue() { return $this->getStorage()->getNewValue(); } final protected function renderAuthor() { $author_phid = $this->getAuthorPHID(); return $this->getStorage()->renderHandleLink($author_phid); } final protected function renderObject() { $object_phid = $this->getObjectPHID(); return $this->getStorage()->renderHandleLink($object_phid); } final protected function renderHandle($phid) { $viewer = $this->getViewer(); $display = $viewer->renderHandle($phid); if ($this->isTextMode()) { $display->setAsText(true); } return $display; } final protected function renderOldHandle() { return $this->renderHandle($this->getOldValue()); } final protected function renderNewHandle() { return $this->renderHandle($this->getNewValue()); } final protected function renderHandleList(array $phids) { $viewer = $this->getViewer(); $display = $viewer->renderHandleList($phids) ->setAsInline(true); if ($this->isTextMode()) { $display->setAsText(true); } return $display; } final protected function renderValue($value) { if ($this->isTextMode()) { return sprintf('"%s"', $value); } return phutil_tag( 'span', array( 'class' => 'phui-timeline-value', ), $value); } + final protected function renderValueList(array $values) { + $result = array(); + foreach ($values as $value) { + $result[] = $this->renderValue($value); + } + + if ($this->isTextMode()) { + return implode(', ', $result); + } + + return phutil_implode_html(', ', $result); + } + final protected function renderOldValue() { return $this->renderValue($this->getOldValue()); } final protected function renderNewValue() { return $this->renderValue($this->getNewValue()); } final protected function renderDate($epoch) { $viewer = $this->getViewer(); // We accept either epoch timestamps or dictionaries describing a // PhutilCalendarDateTime. if (is_array($epoch)) { $datetime = PhutilCalendarAbsoluteDateTime::newFromDictionary($epoch) ->setViewerTimezone($viewer->getTimezoneIdentifier()); $all_day = $datetime->getIsAllDay(); $epoch = $datetime->getEpoch(); } else { $all_day = false; } if ($all_day) { $display = phabricator_date($epoch, $viewer); } else if ($this->isRenderingTargetExternal()) { // When rendering to text, we explicitly render the offset from UTC to // provide context to the date: the mail may be generating with the // server's settings, or the user may later refer back to it after // changing timezones. $display = phabricator_datetimezone($epoch, $viewer); } else { $display = phabricator_datetime($epoch, $viewer); } return $this->renderValue($display); } final protected function renderOldDate() { return $this->renderDate($this->getOldValue()); } final protected function renderNewDate() { return $this->renderDate($this->getNewValue()); } final protected function newError($title, $message, $xaction = null) { return new PhabricatorApplicationTransactionValidationError( $this->getTransactionTypeConstant(), $title, $message, $xaction); } final protected function newRequiredError($message, $xaction = null) { return $this->newError(pht('Required'), $message, $xaction) ->setIsMissingFieldError(true); } final protected function newInvalidError($message, $xaction = null) { return $this->newError(pht('Invalid'), $message, $xaction); } final protected function isNewObject() { return $this->getEditor()->getIsNewObject(); } final protected function isEmptyTextTransaction($value, array $xactions) { foreach ($xactions as $xaction) { $value = $xaction->getNewValue(); } return !strlen($value); } /** * When rendering to external targets (Email/Asana/etc), we need to include * more information that users can't obtain later. */ final protected function isRenderingTargetExternal() { // Right now, this is our best proxy for this: return $this->isTextMode(); // "TARGET_TEXT" means "EMail" and "TARGET_HTML" means "Web". } final protected function isTextMode() { $target = $this->getStorage()->getRenderingTarget(); return ($target == PhabricatorApplicationTransaction::TARGET_TEXT); } final protected function newRemarkupChange() { return id(new PhabricatorTransactionRemarkupChange()) ->setTransaction($this->getStorage()); } final protected function isCreateTransaction() { return $this->getStorage()->getIsCreateTransaction(); } final protected function getPHIDList(array $old, array $new) { $editor = $this->getEditor(); return $editor->getPHIDList($old, $new); } public function getMetadataValue($key, $default = null) { return $this->getStorage()->getMetadataValue($key, $default); } }