From f0ae75c9916a59c5005f4618af2d2b146e56c02f Mon Sep 17 00:00:00 2001 From: epriestley Date: Sat, 23 Mar 2019 10:41:06 -0700 Subject: [PATCH 01/52] Fix an issue with "nextPage()" on worker trigger queries Ref T13266. We never page these queries, and previously never reached the "nextPage()" method. The call order changed recently and this method is now reachable. For now, just no-op it rather than throwing. --- .../daemon/workers/query/PhabricatorWorkerTriggerQuery.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php index 87c16a48c5..8ca12d60e4 100644 --- a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php +++ b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php @@ -70,7 +70,11 @@ final class PhabricatorWorkerTriggerQuery protected function nextPage(array $page) { // NOTE: We don't implement paging because we don't currently ever need // it and paging ORDER_EXECUTION is a hassle. - throw new PhutilMethodNotImplementedException(); + + // (Before T13266, we raised an exception here, but since "nextPage()" is + // now called even if we don't page we can't do that anymore. Just do + // nothing instead.) + return null; } protected function loadPage() { From b8255707349291f63cae791c05be41c2adab82ce Mon Sep 17 00:00:00 2001 From: epriestley Date: Sun, 24 Mar 2019 07:45:39 -0700 Subject: [PATCH 02/52] Fix transaction queries failing on "withIDs()" after clicking "Show Older" Summary: See . Before T13266, this query got away without having real paging because it used simple ID paging only and results are never actually hidden (today, you can always see all transactions on an object). Provide `withIDs()` so the new, slightly stricter paging works. Test Plan: On an object with "Show Older" in the transaction record, clicked the link. Before: exception in paging code (see Discourse link above). After: transactions loaded cleanly. Reviewers: amckinley, avivey Reviewed By: avivey Differential Revision: https://secure.phabricator.com/D20317 --- .../PhabricatorApplicationTransactionQuery.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php b/src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php index f15522a087..1db622163d 100644 --- a/src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php +++ b/src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php @@ -3,6 +3,7 @@ abstract class PhabricatorApplicationTransactionQuery extends PhabricatorCursorPagedPolicyAwareQuery { + private $ids; private $phids; private $objectPHIDs; private $authorPHIDs; @@ -35,6 +36,11 @@ abstract class PhabricatorApplicationTransactionQuery abstract public function getTemplateApplicationTransaction(); + public function withIDs(array $ids) { + $this->ids = $ids; + return $this; + } + public function withPHIDs(array $phids) { $this->phids = $phids; return $this; @@ -157,6 +163,13 @@ abstract class PhabricatorApplicationTransactionQuery protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'x.id IN (%Ld)', + $this->ids); + } + if ($this->phids !== null) { $where[] = qsprintf( $conn, From f047b90d938358c262cda7b755ec6da4162ce19f Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 09:57:12 -0700 Subject: [PATCH 03/52] Don't draw the task graph line image on devices by default Summary: See downstream . On mobile, the task graph can take up most of the screen. Hide it on devices. Keep it on the standalone view if you're really dedicated and willing to rotate your phone or whatever to see the lines. Test Plan: Dragged window real narrow, saw graph hide. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20313 --- .../ManiphestTaskGraphController.php | 1 + .../graph/ManiphestTaskGraph.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/applications/maniphest/controller/ManiphestTaskGraphController.php b/src/applications/maniphest/controller/ManiphestTaskGraphController.php index 2f342a2d0f..f4655d1835 100644 --- a/src/applications/maniphest/controller/ManiphestTaskGraphController.php +++ b/src/applications/maniphest/controller/ManiphestTaskGraphController.php @@ -30,6 +30,7 @@ final class ManiphestTaskGraphController ->setViewer($viewer) ->setSeedPHID($task->getPHID()) ->setLimit($graph_limit) + ->setIsStandalone(true) ->loadGraph(); if (!$task_graph->isEmpty()) { $parent_type = ManiphestTaskDependedOnByTaskEdgeType::EDGECONST; diff --git a/src/infrastructure/graph/ManiphestTaskGraph.php b/src/infrastructure/graph/ManiphestTaskGraph.php index 99191760dd..74a1fe8701 100644 --- a/src/infrastructure/graph/ManiphestTaskGraph.php +++ b/src/infrastructure/graph/ManiphestTaskGraph.php @@ -4,6 +4,7 @@ final class ManiphestTaskGraph extends PhabricatorObjectGraph { private $seedMaps = array(); + private $isStandalone; protected function getEdgeTypes() { return array( @@ -24,6 +25,15 @@ final class ManiphestTaskGraph return $object->isClosed(); } + public function setIsStandalone($is_standalone) { + $this->isStandalone = $is_standalone; + return $this; + } + + public function getIsStandalone() { + return $this->isStandalone; + } + protected function newTableRow($phid, $object, $trace) { $viewer = $this->getViewer(); @@ -132,6 +142,14 @@ final class ManiphestTaskGraph array( true, !$this->getRenderOnlyAdjacentNodes(), + )) + ->setDeviceVisibility( + array( + true, + + // On mobile, we only show the actual graph drawing if we're on the + // standalone page, since it can take over the screen otherwise. + $this->getIsStandalone(), )); } From 7f90570636af8b0fa57d0cab06913e99b2ac27ef Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 19 Mar 2019 10:34:51 -0700 Subject: [PATCH 04/52] When paging by Ferret "rank", page using "HAVING rank > ...", not "WHERE rank > ..." Summary: Ref T13091. The Ferret "rank" column is a function of the query text and looks something like `SELECT ..., 2 + 2 AS rank, ...`. You can't apply conditions to this kind of dynamic column with a WHERE clause: you get a slightly unhelpful error like "column rank unknown in where clause". You must use HAVING: ``` mysql> SELECT 2 + 2 AS x WHERE x = 4; ERROR 1054 (42S22): Unknown column 'x' in 'where clause' mysql> SELECT 2 + 2 AS x HAVING x = 4; +---+ | x | +---+ | 4 | +---+ 1 row in set (0.00 sec) ``` Add a flag to paging column definitions to let them specify that they must be applied with HAVING, then apply the whole paging clause with HAVING if any column requires HAVING. Test Plan: - In Maniphest, ran a fulltext search matching more than 100 results, ordered by "Relevance", then clicked "Next Page". - Before patch: query with `... WHERE rank > 123 OR ...` caused MySQL error because `rank` is not a WHERE-able column. - After patch: query builds as `... HAVING rank > 123 OR ...`, pages properly, no MySQL error. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13091 Differential Revision: https://secure.phabricator.com/D20298 --- ...PhabricatorCursorPagedPolicyAwareQuery.php | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index cf5343dc45..378c282e14 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -83,6 +83,13 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery $this->applyExternalCursorConstraintsToQuery($query, $cursor); + // If we have a Ferret fulltext query, copy it to the subquery so that we + // generate ranking columns appropriately, and compute the correct object + // ranking score for the current query. + if ($this->ferretEngine) { + $query->withFerretConstraint($this->ferretEngine, $this->ferretTokens); + } + // We're executing the subquery normally to make sure the viewer can // actually see the object, and that it's a completely valid object which // passes all filtering and policy checks. You aren't allowed to use an @@ -204,6 +211,19 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery get_class($this))); } + if ($this->supportsFerretEngine()) { + if ($this->getFerretTokens()) { + $map += array( + 'rank' => + $cursor->getRawRowProperty(self::FULLTEXT_RANK), + 'fulltext-modified' => + $cursor->getRawRowProperty(self::FULLTEXT_MODIFIED), + 'fulltext-created' => + $cursor->getRawRowProperty(self::FULLTEXT_CREATED), + ); + } + } + foreach ($keys as $key) { if (!array_key_exists($key, $map)) { throw new Exception( @@ -295,6 +315,8 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } protected function didLoadRawRows(array $rows) { + $this->rawCursorRow = last($rows); + if ($this->ferretEngine) { foreach ($rows as $row) { $phid = $row['phid']; @@ -312,8 +334,6 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } } - $this->rawCursorRow = last($rows); - return $rows; } @@ -467,7 +487,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery */ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = array(); - $where[] = $this->buildPagingClause($conn); + $where[] = $this->buildPagingWhereClause($conn); $where[] = $this->buildEdgeLogicWhereClause($conn); $where[] = $this->buildSpacesWhereClause($conn); $where[] = $this->buildNgramsWhereClause($conn); @@ -482,6 +502,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery */ protected function buildHavingClause(AphrontDatabaseConnection $conn) { $having = $this->buildHavingClauseParts($conn); + $having[] = $this->buildPagingHavingClause($conn); return $this->formatHavingClause($conn, $having); } @@ -539,6 +560,45 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery /* -( Paging )------------------------------------------------------------- */ + private function buildPagingWhereClause(AphrontDatabaseConnection $conn) { + if ($this->shouldPageWithHavingClause()) { + return null; + } + + return $this->buildPagingClause($conn); + } + + private function buildPagingHavingClause(AphrontDatabaseConnection $conn) { + if (!$this->shouldPageWithHavingClause()) { + return null; + } + + return $this->buildPagingClause($conn); + } + + private function shouldPageWithHavingClause() { + // If any of the paging conditions reference dynamic columns, we need to + // put the paging conditions in a "HAVING" clause instead of a "WHERE" + // clause. + + // For example, this happens when paging on the Ferret "rank" column, + // since the "rank" value is computed dynamically in the SELECT statement. + + $orderable = $this->getOrderableColumns(); + $vector = $this->getOrderVector(); + + foreach ($vector as $order) { + $key = $order->getOrderKey(); + $column = $orderable[$key]; + + if (!empty($column['having'])) { + return true; + } + } + + return false; + } + /** * @task paging */ @@ -655,6 +715,8 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery 'reverse' => 'optional bool', 'unique' => 'optional bool', 'null' => 'optional string|null', + 'requires-ferret' => 'optional bool', + 'having' => 'optional bool', )); } @@ -1106,6 +1168,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery 'column' => self::FULLTEXT_RANK, 'type' => 'int', 'requires-ferret' => true, + 'having' => true, ); $columns['fulltext-created'] = array( 'table' => null, From 2ebe675ae5ec280a80cbe5e13cefb21af75837be Mon Sep 17 00:00:00 2001 From: epriestley Date: Sun, 24 Mar 2019 13:56:35 -0700 Subject: [PATCH 05/52] Fix an unusual internal cursor in Conpherence Summary: See . Conpherence calls `setAfterID()` and `setBeforeID()` directly on a subquery, but these methods no longer exist. Use a pager instead. This code probably shouldn't exist (we should use some other approach to fetch this data in most cases) but that's a larger change. Test Plan: Sent messages in a Conpherence thread. Before: fatal; after: success. Viewed the Conphrence menu, loaded threads, etc. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20318 --- .../query/ConpherenceThreadQuery.php | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/applications/conpherence/query/ConpherenceThreadQuery.php b/src/applications/conpherence/query/ConpherenceThreadQuery.php index 5cd6489d65..9c6682a8a7 100644 --- a/src/applications/conpherence/query/ConpherenceThreadQuery.php +++ b/src/applications/conpherence/query/ConpherenceThreadQuery.php @@ -285,23 +285,35 @@ final class ConpherenceThreadQuery } private function loadTransactionsAndHandles(array $conpherences) { - $query = id(new ConpherenceTransactionQuery()) - ->setViewer($this->getViewer()) - ->withObjectPHIDs(array_keys($conpherences)) - ->needHandles(true); + // NOTE: This is older code which has been modernized to the minimum + // standard required by T13266. It probably isn't the best available + // approach to the problems it solves. + + $limit = $this->getTransactionLimit(); + if ($limit) { + // fetch an extra for "show older" scenarios + $limit = $limit + 1; + } else { + $limit = 0xFFFF; + } + + $pager = id(new AphrontCursorPagerView()) + ->setPageSize($limit); // We have to flip these for the underlying query class. The semantics of // paging are tricky business. if ($this->afterTransactionID) { - $query->setBeforeID($this->afterTransactionID); + $pager->setBeforeID($this->afterTransactionID); } else if ($this->beforeTransactionID) { - $query->setAfterID($this->beforeTransactionID); + $pager->setAfterID($this->beforeTransactionID); } - if ($this->getTransactionLimit()) { - // fetch an extra for "show older" scenarios - $query->setLimit($this->getTransactionLimit() + 1); - } - $transactions = $query->execute(); + + $transactions = id(new ConpherenceTransactionQuery()) + ->setViewer($this->getViewer()) + ->withObjectPHIDs(array_keys($conpherences)) + ->needHandles(true) + ->executeWithCursorPager($pager); + $transactions = mgroup($transactions, 'getObjectPHID'); foreach ($conpherences as $phid => $conpherence) { $current_transactions = idx($transactions, $phid, array()); From 1d73ae3b50a6a477c2328c47c60cde5d08b17712 Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 15:04:36 -0700 Subject: [PATCH 06/52] Fix two minor timezone display issues Summary: Ref T13263. Two minor issues: - The "reconcile" dialog shows the wrong sign because JS signs differ from normal signs (for example, PST or PDT or whatever we're in right now is shown as "UTC+7", but should be "UTC-7"). - The big dropdown of possible timezones lumps "UTC+X:30" timezones into "UTC+X". Test Plan: - Reconciled "America/Nome", saw negative UTC offsets for "America/Nome" and "America/Los_Angeles" (previously: improperly positive). - Viewed the big timzone list, saw ":30" and ":45" timezones grouped/labeled more accurately. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13263 Differential Revision: https://secure.phabricator.com/D20314 --- .../PhabricatorSettingsTimezoneController.php | 5 +++++ .../settings/setting/PhabricatorTimezoneSetting.php | 13 ++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php b/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php index 51f1747b9f..6a0ba19d03 100644 --- a/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php +++ b/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php @@ -113,6 +113,11 @@ final class PhabricatorSettingsTimezoneController } private function formatOffset($offset) { + // This controller works with client-side (Javascript) offsets, which have + // the opposite sign we might expect -- for example "UTC-3" is a positive + // offset. Invert the sign before rendering the offset. + $offset = -1 * $offset; + $hours = $offset / 60; // Non-integer number of hours off UTC? if ($offset % 60) { diff --git a/src/applications/settings/setting/PhabricatorTimezoneSetting.php b/src/applications/settings/setting/PhabricatorTimezoneSetting.php index 887e08129b..52fce77428 100644 --- a/src/applications/settings/setting/PhabricatorTimezoneSetting.php +++ b/src/applications/settings/setting/PhabricatorTimezoneSetting.php @@ -57,11 +57,11 @@ final class PhabricatorTimezoneSetting $groups = array(); foreach ($timezones as $timezone) { $zone = new DateTimeZone($timezone); - $offset = -($zone->getOffset($now) / (60 * 60)); + $offset = ($zone->getOffset($now) / 60); $groups[$offset][] = $timezone; } - krsort($groups); + ksort($groups); $option_groups = array( array( @@ -71,10 +71,13 @@ final class PhabricatorTimezoneSetting ); foreach ($groups as $offset => $group) { - if ($offset >= 0) { - $label = pht('UTC-%d', $offset); + $hours = $offset / 60; + $minutes = abs($offset % 60); + + if ($offset % 60) { + $label = pht('UTC%+d:%02d', $hours, $minutes); } else { - $label = pht('UTC+%d', -$offset); + $label = pht('UTC%+d', $hours); } sort($group); From c3563ca15608ba0474aa4713abcdf85ce1a972e7 Mon Sep 17 00:00:00 2001 From: epriestley Date: Mon, 25 Mar 2019 07:27:24 -0700 Subject: [PATCH 07/52] Correct use of the paging API in Phame Summary: Ref T13266. This callsite is using the older API; swap it to use pagers. Test Plan: Viewed a Phame blog post with siblings, saw the previous/next posts linked. Reviewers: amckinley Reviewed By: amckinley Subscribers: nicolast Maniphest Tasks: T13263, T13266 Differential Revision: https://secure.phabricator.com/D20319 --- .../controller/post/PhamePostViewController.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/applications/phame/controller/post/PhamePostViewController.php b/src/applications/phame/controller/post/PhamePostViewController.php index 11d94d2f94..4fb01c4def 100644 --- a/src/applications/phame/controller/post/PhamePostViewController.php +++ b/src/applications/phame/controller/post/PhamePostViewController.php @@ -304,6 +304,15 @@ final class PhamePostViewController private function loadAdjacentPosts(PhamePost $post) { $viewer = $this->getViewer(); + $pager = id(new AphrontCursorPagerView()) + ->setPageSize(1); + + $prev_pager = id(clone $pager) + ->setAfterID($post->getID()); + + $next_pager = id(clone $pager) + ->setBeforeID($post->getID()); + $query = id(new PhamePostQuery()) ->setViewer($viewer) ->withVisibility(array(PhameConstants::VISIBILITY_PUBLISHED)) @@ -311,12 +320,10 @@ final class PhamePostViewController ->setLimit(1); $prev = id(clone $query) - ->setAfterID($post->getID()) - ->execute(); + ->executeWithCursorPager($prev_pager); $next = id(clone $query) - ->setBeforeID($post->getID()) - ->execute(); + ->executeWithCursorPager($next_pager); return array(head($prev), head($next)); } From 79e36dc7fa39a050c7d8d708684974de0d39046b Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 06:41:00 -0700 Subject: [PATCH 08/52] Explain the relationship between "Runnable" and "Restartable" more clearly in Build Plans Summary: See PHI1153. The "Runnable" and "Restartable" behaviors interact (to click "restart", you must be able to run the build AND it must be restartable). Make this more clear. Test Plan: {F6301739} Reviewers: amckinley Reviewed By: amckinley Subscribers: PHID-OPKG-gm6ozazyms6q6i22gyam Differential Revision: https://secure.phabricator.com/D20307 --- .../plan/HarbormasterBuildPlanBehavior.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php index d8e857e711..112926c47c 100644 --- a/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php +++ b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php @@ -350,15 +350,19 @@ final class HarbormasterBuildPlanBehavior ->setKey(self::BEHAVIOR_RESTARTABLE) ->setEditInstructions( pht( - 'Usually, builds may be restarted. This may be useful if you '. - 'suspect a build has failed for environmental or circumstantial '. - 'reasons unrelated to the actual code, and want to give it '. - 'another chance at glory.'. + 'Usually, builds may be restarted by users who have permission '. + 'to edit the related build plan. (You can change who is allowed '. + 'to restart a build by adjusting the "Runnable" behavior.)'. + "\n\n". + 'Restarting a build may be useful if you suspect it has failed '. + 'for environmental or circumstantial reasons unrelated to the '. + 'actual code, and want to give it another chance at glory.'. "\n\n". 'If you want to prevent a build from being restarted, you can '. - 'change the behavior here. This may be useful to prevent '. - 'accidents where a build with a dangerous side effect (like '. - 'deployment) is restarted improperly.')) + 'change when it may be restarted by adjusting this behavior. '. + 'This may be useful to prevent accidents where a build with a '. + 'dangerous side effect (like deployment) is restarted '. + 'improperly.')) ->setName(pht('Restartable')) ->setOptions($restart_options), id(new self()) From e15b3dd3c610923f198ec11f604bbbfc105d776e Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 09:06:10 -0700 Subject: [PATCH 09/52] When a repository is inactive, mark its handle as "closed" Summary: See downstream . We currently don't mark repository handles as closed. Test Plan: Mentioned two repositories with `R1` (active) and `R2` (inactive). After patch, saw `R2` visually indicated as closed/inactive. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20309 --- .../phid/PhabricatorRepositoryRepositoryPHIDType.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/applications/repository/phid/PhabricatorRepositoryRepositoryPHIDType.php b/src/applications/repository/phid/PhabricatorRepositoryRepositoryPHIDType.php index ba78b0fe7a..6ca67257cf 100644 --- a/src/applications/repository/phid/PhabricatorRepositoryRepositoryPHIDType.php +++ b/src/applications/repository/phid/PhabricatorRepositoryRepositoryPHIDType.php @@ -46,6 +46,10 @@ final class PhabricatorRepositoryRepositoryPHIDType ->setFullName("{$monogram} {$name}") ->setURI($uri) ->setMailStampName($monogram); + + if ($repository->getStatus() !== PhabricatorRepository::STATUS_ACTIVE) { + $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); + } } } From 930cc7a6dd70d5076722d65a1a85da864ddf766f Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 09:22:54 -0700 Subject: [PATCH 10/52] Generalize Legalpad validation logic for "Require Signature" Summary: See downstream . I can't actually reproduce any issue here (we only show this field when creating a document, and only if the viewer is an administrator), so maybe this relied on some changes or was originally reported against older code. Regardless, the validation isn't quite right: it requires administrator privileges to apply this transaction at all, but should only require administrator privileges to change the value. Test Plan: Edited Legalpad documents as an administrator and non-administrator before and after the change, with and without signatures being required. Couldn't reproduce the original issue, but this version is generally more correct/robust. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20311 --- ...padDocumentRequireSignatureTransaction.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php b/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php index 3819f38a70..9932baab80 100644 --- a/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php +++ b/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php @@ -55,11 +55,22 @@ final class LegalpadDocumentRequireSignatureTransaction public function validateTransactions($object, array $xactions) { $errors = array(); - $is_admin = $this->getActor()->getIsAdmin(); + $old = (bool)$object->getRequireSignature(); + foreach ($xactions as $xaction) { + $new = (bool)$xaction->getNewValue(); - if (!$is_admin) { - $errors[] = $this->newInvalidError( - pht('Only admins may require signature.')); + if ($old === $new) { + continue; + } + + $is_admin = $this->getActor()->getIsAdmin(); + if (!$is_admin) { + $errors[] = $this->newInvalidError( + pht( + 'Only administrators may change whether a document '. + 'requires a signature.'), + $xaction); + } } return $errors; From b081053e2694e277b1a0f57b8dd8e33863c26fd0 Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 09:32:13 -0700 Subject: [PATCH 11/52] Don't show a "Manage" button in Legalpad if the user is signing a TOS document Summary: When a TOS-like Legalpad document is marked "Require this document to use Phabricator", the login prompt shows a "Manage" button, but that button doesn't work. When we're presenting a document as a session gate, don't show "Manage". Test Plan: Viewed a required document during a session gate (no "Manage" button) and normally (saw "Manage" button). Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20312 --- .../base/controller/PhabricatorController.php | 1 + .../LegalpadDocumentSignController.php | 22 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/applications/base/controller/PhabricatorController.php b/src/applications/base/controller/PhabricatorController.php index 59df22a8aa..cfd0eaee65 100644 --- a/src/applications/base/controller/PhabricatorController.php +++ b/src/applications/base/controller/PhabricatorController.php @@ -608,6 +608,7 @@ abstract class PhabricatorController extends AphrontController { $this->setCurrentApplication($application); $controller = new LegalpadDocumentSignController(); + $controller->setIsSessionGate(true); return $this->delegateToController($controller); } diff --git a/src/applications/legalpad/controller/LegalpadDocumentSignController.php b/src/applications/legalpad/controller/LegalpadDocumentSignController.php index f09d95af29..fb15e2af8f 100644 --- a/src/applications/legalpad/controller/LegalpadDocumentSignController.php +++ b/src/applications/legalpad/controller/LegalpadDocumentSignController.php @@ -2,6 +2,8 @@ final class LegalpadDocumentSignController extends LegalpadController { + private $isSessionGate; + public function shouldAllowPublic() { return true; } @@ -10,6 +12,15 @@ final class LegalpadDocumentSignController extends LegalpadController { return true; } + public function setIsSessionGate($is_session_gate) { + $this->isSessionGate = $is_session_gate; + return $this; + } + + public function getIsSessionGate() { + return $this->isSessionGate; + } + public function handleRequest(AphrontRequest $request) { $viewer = $request->getUser(); @@ -251,8 +262,14 @@ final class LegalpadDocumentSignController extends LegalpadController { $header = id(new PHUIHeaderView()) ->setHeader($title) ->setUser($viewer) - ->setEpoch($content_updated) - ->addActionLink( + ->setEpoch($content_updated); + + // If we're showing the user this document because it's required to use + // Phabricator and they haven't signed it, don't show the "Manage" button, + // since it won't work. + $is_gate = $this->getIsSessionGate(); + if (!$is_gate) { + $header->addActionLink( id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-pencil') @@ -260,6 +277,7 @@ final class LegalpadDocumentSignController extends LegalpadController { ->setHref($manage_uri) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); + } $preamble_box = null; if (strlen($document->getPreamble())) { From a5226366d3155ede9bcbd3a2a62a6eac6aaae20f Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 09:11:53 -0700 Subject: [PATCH 12/52] Make notifications visually clearer, like Feed Summary: See downstream . The notifications menu is missing some CSS to color and style values in stories like "renamed task from X to Y". Test Plan: Before: {F6302123} After: {F6302122} Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20310 --- resources/celerity/map.php | 6 +++--- webroot/rsrc/css/application/base/notification-menu.css | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index b80ce31d1a..08bcbff8bf 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -9,7 +9,7 @@ return array( 'names' => array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', - 'core.pkg.css' => '34ce1741', + 'core.pkg.css' => 'b797945d', 'core.pkg.js' => 'f9c2509b', 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', @@ -38,7 +38,7 @@ return array( 'rsrc/css/application/almanac/almanac.css' => '2e050f4f', 'rsrc/css/application/auth/auth.css' => 'add92fd8', 'rsrc/css/application/base/main-menu-view.css' => '8e2d9a28', - 'rsrc/css/application/base/notification-menu.css' => 'e6962e89', + 'rsrc/css/application/base/notification-menu.css' => '4df1ee30', 'rsrc/css/application/base/phui-theme.css' => '35883b37', 'rsrc/css/application/base/standard-page-view.css' => '8a295cb9', 'rsrc/css/application/chatlog/chatlog.css' => 'abdc76ee', @@ -774,7 +774,7 @@ return array( 'phabricator-nav-view-css' => 'f8a0c1bf', 'phabricator-notification' => 'a9b91e3f', 'phabricator-notification-css' => '30240bd2', - 'phabricator-notification-menu-css' => 'e6962e89', + 'phabricator-notification-menu-css' => '4df1ee30', 'phabricator-object-selector-css' => 'ee77366f', 'phabricator-phtize' => '2f1db1ed', 'phabricator-prefab' => '5793d835', diff --git a/webroot/rsrc/css/application/base/notification-menu.css b/webroot/rsrc/css/application/base/notification-menu.css index 8db2436891..5886798600 100644 --- a/webroot/rsrc/css/application/base/notification-menu.css +++ b/webroot/rsrc/css/application/base/notification-menu.css @@ -15,6 +15,7 @@ .phabricator-notification { padding: 8px 12px; + color: {$darkgreytext}; } .phabricator-notification-menu-loading { @@ -114,7 +115,7 @@ } .phabricator-notification-header a { - color: {$darkgreytext}; + color: {$anchor}; } .phabricator-notification-header a:hover { @@ -162,3 +163,8 @@ .aphlict-connection-status .connection-status-text { margin-left: 12px; } + +.phabricator-notification .phui-timeline-value { + font-style: italic; + color: #000; +} From 917fedafe69e1999f3bc2cdff88c4746194943fc Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 15:59:55 -0700 Subject: [PATCH 13/52] Support exporting custom "Options/Select" fields to Excel/JSON/CSV/etc Summary: See . In JSON, export both the internal key and the visible value. For other formats, export the visible label. Test Plan: - Added a custom options/select field. - Exported CSV, JSON, Text, got sensible output. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20316 --- src/__phutil_library_map__.php | 2 + .../PhabricatorStandardCustomFieldSelect.php | 5 ++ .../field/PhabricatorOptionExportField.php | 47 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 src/infrastructure/export/field/PhabricatorOptionExportField.php diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 5725b5330b..876892943f 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -3692,6 +3692,7 @@ phutil_register_library_map(array( 'PhabricatorOlderInlinesSetting' => 'applications/settings/setting/PhabricatorOlderInlinesSetting.php', 'PhabricatorOneTimeTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php', 'PhabricatorOpcodeCacheSpec' => 'applications/cache/spec/PhabricatorOpcodeCacheSpec.php', + 'PhabricatorOptionExportField' => 'infrastructure/export/field/PhabricatorOptionExportField.php', 'PhabricatorOptionGroupSetting' => 'applications/settings/setting/PhabricatorOptionGroupSetting.php', 'PhabricatorOwnerPathQuery' => 'applications/owners/query/PhabricatorOwnerPathQuery.php', 'PhabricatorOwnersApplication' => 'applications/owners/application/PhabricatorOwnersApplication.php', @@ -9687,6 +9688,7 @@ phutil_register_library_map(array( 'PhabricatorOlderInlinesSetting' => 'PhabricatorSelectSetting', 'PhabricatorOneTimeTriggerClock' => 'PhabricatorTriggerClock', 'PhabricatorOpcodeCacheSpec' => 'PhabricatorCacheSpec', + 'PhabricatorOptionExportField' => 'PhabricatorExportField', 'PhabricatorOptionGroupSetting' => 'PhabricatorSetting', 'PhabricatorOwnerPathQuery' => 'Phobject', 'PhabricatorOwnersApplication' => 'PhabricatorApplication', diff --git a/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php b/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php index 036b7301a1..5957afe56a 100644 --- a/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php +++ b/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php @@ -153,4 +153,9 @@ final class PhabricatorStandardCustomFieldSelect ->setOptions($this->getOptions()); } + protected function newExportFieldType() { + return id(new PhabricatorOptionExportField()) + ->setOptions($this->getOptions()); + } + } diff --git a/src/infrastructure/export/field/PhabricatorOptionExportField.php b/src/infrastructure/export/field/PhabricatorOptionExportField.php new file mode 100644 index 0000000000..e6d3e9b45b --- /dev/null +++ b/src/infrastructure/export/field/PhabricatorOptionExportField.php @@ -0,0 +1,47 @@ +options = $options; + return $this; + } + + public function getOptions() { + return $this->options; + } + + public function getNaturalValue($value) { + if ($value === null) { + return $value; + } + + if (!strlen($value)) { + return null; + } + + $options = $this->getOptions(); + + return array( + 'value' => (string)$value, + 'name' => (string)idx($options, $value, $value), + ); + } + + public function getTextValue($value) { + $natural_value = $this->getNaturalValue($value); + if ($natural_value === null) { + return null; + } + + return $natural_value['name']; + } + + public function getPHPExcelValue($value) { + return $this->getTextValue($value); + } + +} From 252b6f2260561a77564d175f15ee75f43999f6c7 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 12 Mar 2019 08:29:14 -0700 Subject: [PATCH 14/52] Provide basic scaffolding for workboard column triggers Summary: Depends on D20278. Ref T5474. This change creates some new empty objects that do nothing, and some new views for looking at those objects. There's no actual useful behavior yet. The "Edit" controller is custom instead of being driven by "EditEngine" because I expect it to be a Herald-style "add new rules" UI, and EditEngine isn't a clean match for those today (although maybe I'll try to move it over). The general idea here is: - Triggers are "real" objects with a real PHID. - Each trigger has a name and a collection of rules, like "Change status to: X" or "Play sound: Y". - Each column may be bound to a trigger. - Multiple columns may share the same trigger. - Later UI refinements will make the cases around "copy trigger" vs "reference the same trigger" vs "create a new ad-hoc trigger" more clear. - Triggers have their own edit policy. - Triggers are always world-visible, like Herald rules. Test Plan: Poked around, created some empty trigger objects, and nothing exploded. This doesn't actually do anything useful yet since triggers can't have any rule behavior and columns can't actually be bound to triggers. Reviewers: amckinley Reviewed By: amckinley Subscribers: PHID-OPKG-gm6ozazyms6q6i22gyam Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20279 --- .../20190312.triggers.01.trigger.sql | 9 + .../20190312.triggers.02.xaction.sql | 19 ++ .../20190312.triggers.03.triggerphid.sql | 2 + src/__phutil_library_map__.php | 31 +++ .../PhabricatorProjectApplication.php | 8 + .../PhabricatorProjectBoardViewController.php | 40 +++- .../PhabricatorProjectTriggerController.php | 16 ++ ...habricatorProjectTriggerEditController.php | 197 ++++++++++++++++++ ...habricatorProjectTriggerListController.php | 16 ++ ...habricatorProjectTriggerViewController.php | 168 +++++++++++++++ .../PhabricatorProjectTriggerEditor.php | 30 +++ .../engine/PhabricatorBoardLayoutEngine.php | 1 + .../PhabricatorProjectTriggerPHIDType.php | 45 ++++ .../query/PhabricatorProjectColumnQuery.php | 55 +++++ .../query/PhabricatorProjectTriggerQuery.php | 51 +++++ .../PhabricatorProjectTriggerSearchEngine.php | 75 +++++++ ...bricatorProjectTriggerTransactionQuery.php | 10 + .../storage/PhabricatorProjectColumn.php | 39 ++++ .../storage/PhabricatorProjectTrigger.php | 108 ++++++++++ .../PhabricatorProjectTriggerTransaction.php | 18 ++ ...abricatorProjectTriggerNameTransaction.php | 58 ++++++ ...abricatorProjectTriggerTransactionType.php | 4 + ...PhabricatorApplicationSearchController.php | 5 +- 23 files changed, 999 insertions(+), 6 deletions(-) create mode 100644 resources/sql/autopatches/20190312.triggers.01.trigger.sql create mode 100644 resources/sql/autopatches/20190312.triggers.02.xaction.sql create mode 100644 resources/sql/autopatches/20190312.triggers.03.triggerphid.sql create mode 100644 src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php create mode 100644 src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php create mode 100644 src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php create mode 100644 src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php create mode 100644 src/applications/project/editor/PhabricatorProjectTriggerEditor.php create mode 100644 src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php create mode 100644 src/applications/project/query/PhabricatorProjectTriggerQuery.php create mode 100644 src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php create mode 100644 src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php create mode 100644 src/applications/project/storage/PhabricatorProjectTrigger.php create mode 100644 src/applications/project/storage/PhabricatorProjectTriggerTransaction.php create mode 100644 src/applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php create mode 100644 src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php diff --git a/resources/sql/autopatches/20190312.triggers.01.trigger.sql b/resources/sql/autopatches/20190312.triggers.01.trigger.sql new file mode 100644 index 0000000000..301a3a62cd --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.01.trigger.sql @@ -0,0 +1,9 @@ +CREATE TABLE {$NAMESPACE}_project.project_trigger ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + name VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT}, + editPolicy VARBINARY(64) NOT NULL, + ruleset LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190312.triggers.02.xaction.sql b/resources/sql/autopatches/20190312.triggers.02.xaction.sql new file mode 100644 index 0000000000..1a6034c4b1 --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.02.xaction.sql @@ -0,0 +1,19 @@ +CREATE TABLE {$NAMESPACE}_project.project_triggertransaction ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + authorPHID VARBINARY(64) NOT NULL, + objectPHID VARBINARY(64) NOT NULL, + viewPolicy VARBINARY(64) NOT NULL, + editPolicy VARBINARY(64) NOT NULL, + commentPHID VARBINARY(64) DEFAULT NULL, + commentVersion INT UNSIGNED NOT NULL, + transactionType VARCHAR(32) NOT NULL, + oldValue LONGTEXT NOT NULL, + newValue LONGTEXT NOT NULL, + contentSource LONGTEXT NOT NULL, + metadata LONGTEXT NOT NULL, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_phid` (`phid`), + KEY `key_object` (`objectPHID`) +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql b/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql new file mode 100644 index 0000000000..271d679cfa --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_project.project_column + ADD triggerPHID VARBINARY(64); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 876892943f..258ebf1f8f 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4166,6 +4166,19 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionEditor' => 'applications/project/editor/PhabricatorProjectTransactionEditor.php', 'PhabricatorProjectTransactionQuery' => 'applications/project/query/PhabricatorProjectTransactionQuery.php', 'PhabricatorProjectTransactionType' => 'applications/project/xaction/PhabricatorProjectTransactionType.php', + 'PhabricatorProjectTrigger' => 'applications/project/storage/PhabricatorProjectTrigger.php', + 'PhabricatorProjectTriggerController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerController.php', + 'PhabricatorProjectTriggerEditController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php', + 'PhabricatorProjectTriggerEditor' => 'applications/project/editor/PhabricatorProjectTriggerEditor.php', + 'PhabricatorProjectTriggerListController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerListController.php', + 'PhabricatorProjectTriggerNameTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php', + 'PhabricatorProjectTriggerPHIDType' => 'applications/project/phid/PhabricatorProjectTriggerPHIDType.php', + 'PhabricatorProjectTriggerQuery' => 'applications/project/query/PhabricatorProjectTriggerQuery.php', + 'PhabricatorProjectTriggerSearchEngine' => 'applications/project/query/PhabricatorProjectTriggerSearchEngine.php', + 'PhabricatorProjectTriggerTransaction' => 'applications/project/storage/PhabricatorProjectTriggerTransaction.php', + 'PhabricatorProjectTriggerTransactionQuery' => 'applications/project/query/PhabricatorProjectTriggerTransactionQuery.php', + 'PhabricatorProjectTriggerTransactionType' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php', + 'PhabricatorProjectTriggerViewController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php', 'PhabricatorProjectTypeTransaction' => 'applications/project/xaction/PhabricatorProjectTypeTransaction.php', 'PhabricatorProjectUIEventListener' => 'applications/project/events/PhabricatorProjectUIEventListener.php', 'PhabricatorProjectUpdateController' => 'applications/project/controller/PhabricatorProjectUpdateController.php', @@ -10268,6 +10281,24 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProjectTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectTrigger' => array( + 'PhabricatorProjectDAO', + 'PhabricatorApplicationTransactionInterface', + 'PhabricatorPolicyInterface', + 'PhabricatorDestructibleInterface', + ), + 'PhabricatorProjectTriggerController' => 'PhabricatorProjectController', + 'PhabricatorProjectTriggerEditController' => 'PhabricatorProjectTriggerController', + 'PhabricatorProjectTriggerEditor' => 'PhabricatorApplicationTransactionEditor', + 'PhabricatorProjectTriggerListController' => 'PhabricatorProjectTriggerController', + 'PhabricatorProjectTriggerNameTransaction' => 'PhabricatorProjectTriggerTransactionType', + 'PhabricatorProjectTriggerPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorProjectTriggerQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorProjectTriggerSearchEngine' => 'PhabricatorApplicationSearchEngine', + 'PhabricatorProjectTriggerTransaction' => 'PhabricatorModularTransaction', + 'PhabricatorProjectTriggerTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'PhabricatorProjectTriggerTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectTriggerViewController' => 'PhabricatorProjectTriggerController', 'PhabricatorProjectTypeTransaction' => 'PhabricatorProjectTransactionType', 'PhabricatorProjectUIEventListener' => 'PhabricatorEventListener', 'PhabricatorProjectUpdateController' => 'PhabricatorProjectController', diff --git a/src/applications/project/application/PhabricatorProjectApplication.php b/src/applications/project/application/PhabricatorProjectApplication.php index 0e1a9f37c7..192b40f6cd 100644 --- a/src/applications/project/application/PhabricatorProjectApplication.php +++ b/src/applications/project/application/PhabricatorProjectApplication.php @@ -89,6 +89,14 @@ final class PhabricatorProjectApplication extends PhabricatorApplication { 'background/' => 'PhabricatorProjectBoardBackgroundController', ), + 'trigger/' => array( + $this->getQueryRoutePattern() => + 'PhabricatorProjectTriggerListController', + '(?P[1-9]\d*)/' => + 'PhabricatorProjectTriggerViewController', + $this->getEditRoutePattern('edit/') => + 'PhabricatorProjectTriggerEditController', + ), 'update/(?P[1-9]\d*)/(?P[^/]+)/' => 'PhabricatorProjectUpdateController', 'manage/(?P[1-9]\d*)/' => 'PhabricatorProjectManageController', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index a1dcd6ab68..9b882c6ccc 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -1111,10 +1111,8 @@ final class PhabricatorProjectBoardViewController )); } - if (count($specs) > 1) { - $column_items[] = id(new PhabricatorActionView()) - ->setType(PhabricatorActionView::TYPE_DIVIDER); - } + $column_items[] = id(new PhabricatorActionView()) + ->setType(PhabricatorActionView::TYPE_DIVIDER); $batch_edit_uri = $request->getRequestURI(); $batch_edit_uri->replaceQueryParam('batch', $column->getID()); @@ -1174,6 +1172,40 @@ final class PhabricatorProjectBoardViewController ->setWorkflow(true); } + if ($column->canHaveTrigger()) { + $column_items[] = id(new PhabricatorActionView()) + ->setType(PhabricatorActionView::TYPE_DIVIDER); + + $trigger = $column->getTrigger(); + if (!$trigger) { + $set_uri = $this->getApplicationURI( + new PhutilURI( + 'trigger/edit/', + array( + 'columnPHID' => $column->getPHID(), + ))); + + $column_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cogs') + ->setName(pht('New Trigger...')) + ->setHref($set_uri) + ->setDisabled(!$can_edit); + } else { + $column_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cogs') + ->setName(pht('View Trigger')) + ->setHref($trigger->getURI()) + ->setDisabled(!$can_edit); + } + + $column_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-times') + ->setName(pht('Remove Trigger')) + ->setHref('#') + ->setWorkflow(true) + ->setDisabled(!$can_edit || !$trigger); + } + $column_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($column_items as $item) { diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php new file mode 100644 index 0000000000..ea729e82a4 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php @@ -0,0 +1,16 @@ +addTextCrumb( + pht('Triggers'), + $this->getApplicationURI('trigger/')); + + return $crumbs; + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php new file mode 100644 index 0000000000..86f75225d2 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php @@ -0,0 +1,197 @@ +getRequest(); + $viewer = $request->getViewer(); + + $id = $request->getURIData('id'); + if ($id) { + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$trigger) { + return new Aphront404Response(); + } + } else { + $trigger = PhabricatorProjectTrigger::initializeNewTrigger(); + } + + $column_phid = $request->getStr('columnPHID'); + if ($column_phid) { + $column = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withPHIDs(array($column_phid)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$column) { + return new Aphront404Response(); + } + $board_uri = $column->getBoardURI(); + } else { + $column = null; + $board_uri = null; + } + + if ($board_uri) { + $cancel_uri = $board_uri; + } else if ($trigger->getID()) { + $cancel_uri = $trigger->getURI(); + } else { + $cancel_uri = $this->getApplicationURI('trigger/'); + } + + $v_name = $trigger->getName(); + $v_edit = $trigger->getEditPolicy(); + + $e_name = null; + $e_edit = null; + + $validation_exception = null; + if ($request->isFormPost()) { + try { + $v_name = $request->getStr('name'); + $v_edit = $request->getStr('editPolicy'); + + $xactions = array(); + if (!$trigger->getID()) { + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType(PhabricatorTransactions::TYPE_CREATE) + ->setNewValue(true); + } + + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectTriggerNameTransaction::TRANSACTIONTYPE) + ->setNewValue($v_name); + + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) + ->setNewValue($v_edit); + + $editor = $trigger->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true); + + $editor->applyTransactions($trigger, $xactions); + + $next_uri = $trigger->getURI(); + + if ($column) { + $column_xactions = array(); + + // TODO: Modularize column transactions so we can change the column + // trigger here. For now, this does nothing. + + $column_editor = $column->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true); + + $column_editor->applyTransactions($column, $column_xactions); + + $next_uri = $column->getBoardURI(); + } + + return id(new AphrontRedirectResponse())->setURI($next_uri); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + $validation_exception = $ex; + + $e_name = $ex->getShortMessage( + PhabricatorProjectTriggerNameTransaction::TRANSACTIONTYPE); + + $e_edit = $ex->getShortMessage( + PhabricatorTransactions::TYPE_EDIT_POLICY); + + $trigger->setEditPolicy($v_edit); + } + } + + if ($trigger->getID()) { + $title = $trigger->getObjectName(); + $submit = pht('Save Trigger'); + $header = pht('Edit Trigger: %s', $trigger->getObjectName()); + } else { + $title = pht('New Trigger'); + $submit = pht('Create Trigger'); + $header = pht('New Trigger'); + } + + $form = id(new AphrontFormView()) + ->setViewer($viewer); + + if ($column) { + $form->addHiddenInput('columnPHID', $column->getPHID()); + } + + $form->appendControl( + id(new AphrontFormTextControl()) + ->setLabel(pht('Name')) + ->setName('name') + ->setValue($v_name) + ->setError($e_name) + ->setPlaceholder($trigger->getDefaultName())); + + $policies = id(new PhabricatorPolicyQuery()) + ->setViewer($viewer) + ->setObject($trigger) + ->execute(); + + $form->appendControl( + id(new AphrontFormPolicyControl()) + ->setName('editPolicy') + ->setPolicyObject($trigger) + ->setCapability(PhabricatorPolicyCapability::CAN_EDIT) + ->setPolicies($policies) + ->setError($e_edit)); + + $form->appendControl( + id(new AphrontFormSubmitControl()) + ->setValue($submit) + ->addCancelButton($cancel_uri)); + + $header = id(new PHUIHeaderView()) + ->setHeader($header); + + $box_view = id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setValidationException($validation_exception) + ->appendChild($form); + + $column_view = id(new PHUITwoColumnView()) + ->setFooter($box_view); + + $crumbs = $this->buildApplicationCrumbs() + ->setBorder(true); + + if ($column) { + $crumbs->addTextCrumb( + pht( + '%s: %s', + $column->getProject()->getDisplayName(), + $column->getName()), + $board_uri); + } + + $crumbs->addTextCrumb($title); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($column_view); + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php new file mode 100644 index 0000000000..62e5430f26 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php @@ -0,0 +1,16 @@ +setController($this) + ->buildResponse(); + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php new file mode 100644 index 0000000000..d1966cf106 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php @@ -0,0 +1,168 @@ +getRequest(); + $viewer = $request->getViewer(); + + $id = $request->getURIData('id'); + + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->executeOne(); + if (!$trigger) { + return new Aphront404Response(); + } + + $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( + $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 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/editor/PhabricatorProjectTriggerEditor.php b/src/applications/project/editor/PhabricatorProjectTriggerEditor.php new file mode 100644 index 0000000000..20098fa370 --- /dev/null +++ b/src/applications/project/editor/PhabricatorProjectTriggerEditor.php @@ -0,0 +1,30 @@ +setViewer($viewer) ->withProjectPHIDs(array_keys($boards)) + ->needTriggers(true) ->execute(); $columns = msort($columns, 'getOrderingKey'); $columns = mpull($columns, null, 'getPHID'); diff --git a/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php b/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php new file mode 100644 index 0000000000..346b0e69fa --- /dev/null +++ b/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php @@ -0,0 +1,45 @@ +withPHIDs($phids); + } + + public function loadHandles( + PhabricatorHandleQuery $query, + array $handles, + array $objects) { + + foreach ($handles as $phid => $handle) { + $trigger = $objects[$phid]; + + $handle->setName($trigger->getDisplayName()); + $handle->setURI($trigger->getURI()); + } + } + +} diff --git a/src/applications/project/query/PhabricatorProjectColumnQuery.php b/src/applications/project/query/PhabricatorProjectColumnQuery.php index 441c33e8cb..03169a7827 100644 --- a/src/applications/project/query/PhabricatorProjectColumnQuery.php +++ b/src/applications/project/query/PhabricatorProjectColumnQuery.php @@ -9,6 +9,8 @@ final class PhabricatorProjectColumnQuery private $proxyPHIDs; private $statuses; private $isProxyColumn; + private $triggerPHIDs; + private $needTriggers; public function withIDs(array $ids) { $this->ids = $ids; @@ -40,6 +42,16 @@ final class PhabricatorProjectColumnQuery return $this; } + public function withTriggerPHIDs(array $trigger_phids) { + $this->triggerPHIDs = $trigger_phids; + return $this; + } + + public function needTriggers($need_triggers) { + $this->needTriggers = true; + return $this; + } + public function newResultObject() { return new PhabricatorProjectColumn(); } @@ -121,6 +133,42 @@ final class PhabricatorProjectColumnQuery $column->attachProxy($proxy); } + if ($this->needTriggers) { + $trigger_phids = array(); + foreach ($page as $column) { + if ($column->canHaveTrigger()) { + $trigger_phid = $column->getTriggerPHID(); + if ($trigger_phid) { + $trigger_phids[] = $trigger_phid; + } + } + } + + if ($trigger_phids) { + $triggers = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($this->getViewer()) + ->setParentQuery($this) + ->withPHIDs(array($this->getPHID())) + ->execute(); + $triggers = mpull($triggers, null, 'getPHID'); + } else { + $triggers = array(); + } + + foreach ($page as $column) { + $trigger = null; + + if ($column->canHaveTrigger()) { + $trigger_phid = $column->getTriggerPHID(); + if ($trigger_phid) { + $trigger = idx($triggers, $trigger_phid); + } + } + + $column->attachTrigger($trigger); + } + } + return $page; } @@ -162,6 +210,13 @@ final class PhabricatorProjectColumnQuery $this->statuses); } + if ($this->triggerPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'triggerPHID IN (%Ls)', + $this->triggerPHIDs); + } + if ($this->isProxyColumn !== null) { if ($this->isProxyColumn) { $where[] = qsprintf($conn, 'proxyPHID IS NOT NULL'); diff --git a/src/applications/project/query/PhabricatorProjectTriggerQuery.php b/src/applications/project/query/PhabricatorProjectTriggerQuery.php new file mode 100644 index 0000000000..e3fab5b3d0 --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerQuery.php @@ -0,0 +1,51 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + public function newResultObject() { + return new PhabricatorProjectTrigger(); + } + + protected function loadPage() { + return $this->loadStandardPage($this->newResultObject()); + } + + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { + $where = parent::buildWhereClauseParts($conn); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn, + 'phid IN (%Ls)', + $this->phids); + } + + return $where; + } + + public function getQueryApplicationClass() { + return 'PhabricatorProjectApplication'; + } + +} diff --git a/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php b/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php new file mode 100644 index 0000000000..6c6a417723 --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php @@ -0,0 +1,75 @@ +newQuery(); + + return $query; + } + + protected function getURI($path) { + return '/project/trigger/'.$path; + } + + protected function getBuiltinQueryNames() { + $names = array(); + + $names['all'] = pht('All'); + + return $names; + } + + public function buildSavedQueryFromBuiltin($query_key) { + $query = $this->newSavedQuery(); + $query->setQueryKey($query_key); + + switch ($query_key) { + case 'all': + return $query; + } + + return parent::buildSavedQueryFromBuiltin($query_key); + } + + protected function renderResultList( + array $triggers, + PhabricatorSavedQuery $query, + array $handles) { + assert_instances_of($triggers, 'PhabricatorProjectTrigger'); + $viewer = $this->requireViewer(); + + $list = id(new PHUIObjectItemListView()) + ->setViewer($viewer); + foreach ($triggers as $trigger) { + $item = id(new PHUIObjectItemView()) + ->setObjectName($trigger->getObjectName()) + ->setHeader($trigger->getDisplayName()) + ->setHref($trigger->getURI()); + + $list->addItem($item); + } + + return id(new PhabricatorApplicationSearchResultView()) + ->setObjectList($list) + ->setNoDataString(pht('No triggers found.')); + } + +} diff --git a/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php b/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php new file mode 100644 index 0000000000..9ec4d4a53b --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php @@ -0,0 +1,10 @@ + 'uint32', 'sequence' => 'uint32', 'proxyPHID' => 'phid?', + 'triggerPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_status' => array( @@ -52,6 +55,9 @@ final class PhabricatorProjectColumn 'columns' => array('projectPHID', 'proxyPHID'), 'unique' => true, ), + 'key_trigger' => array( + 'columns' => array('triggerPHID'), + ), ), ) + parent::getConfiguration(); } @@ -180,6 +186,39 @@ final class PhabricatorProjectColumn return sprintf('%s%012d', $group, $sequence); } + public function attachTrigger(PhabricatorProjectTrigger $trigger = null) { + $this->trigger = $trigger; + return $this; + } + + public function getTrigger() { + return $this->assertAttached($this->trigger); + } + + public function canHaveTrigger() { + // Backlog columns and proxy (subproject / milestone) columns can't have + // triggers because cards routinely end up in these columns through tag + // edits rather than drag-and-drop and it would likely be confusing to + // have these triggers act only a small fraction of the time. + + if ($this->isDefaultColumn()) { + return false; + } + + if ($this->getProxy()) { + return false; + } + + return true; + } + + public function getBoardURI() { + return urisprintf( + '/project/board/%d/', + $this->getProject()->getID()); + } + + /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php new file mode 100644 index 0000000000..7730d90529 --- /dev/null +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -0,0 +1,108 @@ +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()); + } + + +/* -( 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->delete(); + } + +} diff --git a/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php b/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php new file mode 100644 index 0000000000..fb94bdc364 --- /dev/null +++ b/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php @@ -0,0 +1,18 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (strlen($old) && strlen($new)) { + return pht( + '%s renamed this trigger from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } else if (strlen($new)) { + return pht( + '%s named this trigger %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else { + return pht( + '%s stripped the name %s from this trigger.', + $this->renderAuthor(), + $this->renderOldValue()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $max_length = $object->getColumnMaximumByteLength('name'); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + $new_length = strlen($new_value); + if ($new_length > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Trigger names must not be longer than %s characters.', + new PhutilNumber($max_length)), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php new file mode 100644 index 0000000000..30222e1e2c --- /dev/null +++ b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php @@ -0,0 +1,4 @@ +getObjectList()) { From 0204489a526500d9f192eb7103427fc6509254e5 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 14 Mar 2019 08:11:08 -0700 Subject: [PATCH 15/52] Modularize workboard column transactions Summary: Depends on D20279. Ref T5474. Modernize these transactions before I add a new "TriggerTransaction" for setting triggers. Test Plan: Created a column. Edited a column name and point limit. Hid and un-hid a column. Grepped for removed symbols. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20286 --- src/__phutil_library_map__.php | 10 +- ...PhabricatorProjectColumnEditController.php | 5 +- ...PhabricatorProjectColumnHideController.php | 4 +- ...bricatorProjectColumnTransactionEditor.php | 126 +----------------- .../PhabricatorProjectColumnTransaction.php | 70 +--------- ...abricatorProjectColumnLimitTransaction.php | 63 +++++++++ ...habricatorProjectColumnNameTransaction.php | 66 +++++++++ ...bricatorProjectColumnStatusTransaction.php | 55 ++++++++ ...habricatorProjectColumnTransactionType.php | 4 + 9 files changed, 209 insertions(+), 194 deletions(-) create mode 100644 src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php create mode 100644 src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php create mode 100644 src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php create mode 100644 src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 258ebf1f8f..110062e861 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4059,6 +4059,8 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnEditController' => 'applications/project/controller/PhabricatorProjectColumnEditController.php', 'PhabricatorProjectColumnHeader' => 'applications/project/order/PhabricatorProjectColumnHeader.php', 'PhabricatorProjectColumnHideController' => 'applications/project/controller/PhabricatorProjectColumnHideController.php', + 'PhabricatorProjectColumnLimitTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php', + 'PhabricatorProjectColumnNameTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php', 'PhabricatorProjectColumnNaturalOrder' => 'applications/project/order/PhabricatorProjectColumnNaturalOrder.php', 'PhabricatorProjectColumnOrder' => 'applications/project/order/PhabricatorProjectColumnOrder.php', 'PhabricatorProjectColumnOwnerOrder' => 'applications/project/order/PhabricatorProjectColumnOwnerOrder.php', @@ -4070,10 +4072,12 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnQuery' => 'applications/project/query/PhabricatorProjectColumnQuery.php', 'PhabricatorProjectColumnSearchEngine' => 'applications/project/query/PhabricatorProjectColumnSearchEngine.php', 'PhabricatorProjectColumnStatusOrder' => 'applications/project/order/PhabricatorProjectColumnStatusOrder.php', + 'PhabricatorProjectColumnStatusTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php', 'PhabricatorProjectColumnTitleOrder' => 'applications/project/order/PhabricatorProjectColumnTitleOrder.php', 'PhabricatorProjectColumnTransaction' => 'applications/project/storage/PhabricatorProjectColumnTransaction.php', 'PhabricatorProjectColumnTransactionEditor' => 'applications/project/editor/PhabricatorProjectColumnTransactionEditor.php', 'PhabricatorProjectColumnTransactionQuery' => 'applications/project/query/PhabricatorProjectColumnTransactionQuery.php', + 'PhabricatorProjectColumnTransactionType' => 'applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php', 'PhabricatorProjectConfigOptions' => 'applications/project/config/PhabricatorProjectConfigOptions.php', 'PhabricatorProjectConfiguredCustomField' => 'applications/project/customfield/PhabricatorProjectConfiguredCustomField.php', 'PhabricatorProjectController' => 'applications/project/controller/PhabricatorProjectController.php', @@ -10166,6 +10170,8 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnEditController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnHeader' => 'Phobject', 'PhabricatorProjectColumnHideController' => 'PhabricatorProjectBoardController', + 'PhabricatorProjectColumnLimitTransaction' => 'PhabricatorProjectColumnTransactionType', + 'PhabricatorProjectColumnNameTransaction' => 'PhabricatorProjectColumnTransactionType', 'PhabricatorProjectColumnNaturalOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnOrder' => 'Phobject', 'PhabricatorProjectColumnOwnerOrder' => 'PhabricatorProjectColumnOrder', @@ -10180,10 +10186,12 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorProjectColumnSearchEngine' => 'PhabricatorApplicationSearchEngine', 'PhabricatorProjectColumnStatusOrder' => 'PhabricatorProjectColumnOrder', + 'PhabricatorProjectColumnStatusTransaction' => 'PhabricatorProjectColumnTransactionType', 'PhabricatorProjectColumnTitleOrder' => 'PhabricatorProjectColumnOrder', - 'PhabricatorProjectColumnTransaction' => 'PhabricatorApplicationTransaction', + 'PhabricatorProjectColumnTransaction' => 'PhabricatorModularTransaction', 'PhabricatorProjectColumnTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectColumnTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'PhabricatorProjectColumnTransactionType' => 'PhabricatorModularTransactionType', 'PhabricatorProjectConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorProjectConfiguredCustomField' => array( 'PhabricatorProjectStandardCustomField', diff --git a/src/applications/project/controller/PhabricatorProjectColumnEditController.php b/src/applications/project/controller/PhabricatorProjectColumnEditController.php index 94277c92e5..567b923407 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnEditController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnEditController.php @@ -76,8 +76,8 @@ final class PhabricatorProjectColumnEditController $xactions = array(); - $type_name = PhabricatorProjectColumnTransaction::TYPE_NAME; - $type_limit = PhabricatorProjectColumnTransaction::TYPE_LIMIT; + $type_name = PhabricatorProjectColumnNameTransaction::TRANSACTIONTYPE; + $type_limit = PhabricatorProjectColumnLimitTransaction::TRANSACTIONTYPE; if (!$column->getProxy()) { $xactions[] = id(new PhabricatorProjectColumnTransaction()) @@ -93,7 +93,6 @@ final class PhabricatorProjectColumnEditController $editor = id(new PhabricatorProjectColumnTransactionEditor()) ->setActor($viewer) ->setContinueOnNoEffect(true) - ->setContinueOnMissingFields(true) ->setContentSourceFromRequest($request) ->applyTransactions($column, $xactions); return id(new AphrontRedirectResponse())->setURI($view_uri); diff --git a/src/applications/project/controller/PhabricatorProjectColumnHideController.php b/src/applications/project/controller/PhabricatorProjectColumnHideController.php index fbda2feb1e..61811af5c3 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnHideController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnHideController.php @@ -82,7 +82,9 @@ final class PhabricatorProjectColumnHideController $new_status = PhabricatorProjectColumn::STATUS_HIDDEN; } - $type_status = PhabricatorProjectColumnTransaction::TYPE_STATUS; + $type_status = + PhabricatorProjectColumnStatusTransaction::TRANSACTIONTYPE; + $xactions = array( id(new PhabricatorProjectColumnTransaction()) ->setTransactionType($type_status) diff --git a/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php b/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php index d494767085..e0becc3470 100644 --- a/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php +++ b/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php @@ -11,130 +11,12 @@ final class PhabricatorProjectColumnTransactionEditor return pht('Workboard Columns'); } - public function getTransactionTypes() { - $types = parent::getTransactionTypes(); - - $types[] = PhabricatorProjectColumnTransaction::TYPE_NAME; - $types[] = PhabricatorProjectColumnTransaction::TYPE_STATUS; - $types[] = PhabricatorProjectColumnTransaction::TYPE_LIMIT; - - return $types; + public function getCreateObjectTitle($author, $object) { + return pht('%s created this column.', $author); } - protected function getCustomTransactionOldValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - return $object->getName(); - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - return $object->getStatus(); - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - return $object->getPointLimit(); - - } - - return parent::getCustomTransactionOldValue($object, $xaction); - } - - protected function getCustomTransactionNewValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - return $xaction->getNewValue(); - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - $value = $xaction->getNewValue(); - if (strlen($value)) { - return (int)$xaction->getNewValue(); - } else { - return null; - } - } - - return parent::getCustomTransactionNewValue($object, $xaction); - } - - protected function applyCustomInternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - $object->setName($xaction->getNewValue()); - return; - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - $object->setStatus($xaction->getNewValue()); - return; - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - $object->setPointLimit($xaction->getNewValue()); - return; - } - - return parent::applyCustomInternalTransaction($object, $xaction); - } - - protected function applyCustomExternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - return; - } - - return parent::applyCustomExternalTransaction($object, $xaction); - } - - protected function validateTransaction( - PhabricatorLiskDAO $object, - $type, - array $xactions) { - - $errors = parent::validateTransaction($object, $type, $xactions); - - switch ($type) { - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - foreach ($xactions as $xaction) { - $value = $xaction->getNewValue(); - if (strlen($value) && !preg_match('/^\d+\z/', $value)) { - $errors[] = new PhabricatorApplicationTransactionValidationError( - $type, - pht('Invalid'), - pht( - 'Column point limit must either be empty or a nonnegative '. - 'integer.'), - $xaction); - } - } - break; - case PhabricatorProjectColumnTransaction::TYPE_NAME: - $missing = $this->validateIsEmptyTextField( - $object->getName(), - $xactions); - - // The default "Backlog" column is allowed to be unnamed, which - // means we use the default name. - - if ($missing && !$object->isDefaultColumn()) { - $error = new PhabricatorApplicationTransactionValidationError( - $type, - pht('Required'), - pht('Column name is required.'), - nonempty(last($xactions), null)); - - $error->setIsMissingFieldError(true); - $errors[] = $error; - } - break; - } - - return $errors; + public function getCreateObjectTitleForFeed($author, $object) { + return pht('%s created %s.', $author, $object); } } diff --git a/src/applications/project/storage/PhabricatorProjectColumnTransaction.php b/src/applications/project/storage/PhabricatorProjectColumnTransaction.php index ed4bfed8a6..35a7461ca2 100644 --- a/src/applications/project/storage/PhabricatorProjectColumnTransaction.php +++ b/src/applications/project/storage/PhabricatorProjectColumnTransaction.php @@ -1,11 +1,7 @@ getOldValue(); - $new = $this->getNewValue(); - $author_handle = $this->renderHandleLink($this->getAuthorPHID()); - - switch ($this->getTransactionType()) { - case self::TYPE_NAME: - if ($old === null) { - return pht( - '%s created this column.', - $author_handle); - } else { - if (!strlen($old)) { - return pht( - '%s named this column "%s".', - $author_handle, - $new); - } else if (strlen($new)) { - return pht( - '%s renamed this column from "%s" to "%s".', - $author_handle, - $old, - $new); - } else { - return pht( - '%s removed the custom name of this column.', - $author_handle); - } - } - case self::TYPE_LIMIT: - if (!$old) { - return pht( - '%s set the point limit for this column to %s.', - $author_handle, - $new); - } else if (!$new) { - return pht( - '%s removed the point limit for this column.', - $author_handle); - } else { - return pht( - '%s changed point limit for this column from %s to %s.', - $author_handle, - $old, - $new); - } - - case self::TYPE_STATUS: - switch ($new) { - case PhabricatorProjectColumn::STATUS_ACTIVE: - return pht( - '%s marked this column visible.', - $author_handle); - case PhabricatorProjectColumn::STATUS_HIDDEN: - return pht( - '%s marked this column hidden.', - $author_handle); - } - break; - } - - return parent::getTitle(); + public function getBaseTransactionClass() { + return 'PhabricatorProjectColumnTransactionType'; } } diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php new file mode 100644 index 0000000000..8e91ccbe5d --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php @@ -0,0 +1,63 @@ +getPointLimit(); + } + + public function generateNewValue($object, $value) { + if (strlen($value)) { + return (int)$value; + } else { + return null; + } + } + + public function applyInternalEffects($object, $value) { + $object->setPointLimit($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!$old) { + return pht( + '%s set the point limit for this column to %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else if (!$new) { + return pht( + '%s removed the point limit for this column.', + $this->renderAuthor()); + } else { + return pht( + '%s changed the point limit for this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + foreach ($xactions as $xaction) { + $value = $xaction->getNewValue(); + if (strlen($value) && !preg_match('/^\d+\z/', $value)) { + $errors[] = $this->newInvalidError( + pht( + 'Column point limit must either be empty or a nonnegative '. + 'integer.'), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php new file mode 100644 index 0000000000..bff54277de --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php @@ -0,0 +1,66 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!strlen($old)) { + return pht( + '%s named this column %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else if (strlen($new)) { + return pht( + '%s renamed this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } else { + return pht( + '%s removed the custom name of this column.', + $this->renderAuthor()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + if ($this->isEmptyTextTransaction($object->getName(), $xactions)) { + // The default "Backlog" column is allowed to be unnamed, which + // means we use the default name. + if (!$object->isDefaultColumn()) { + $errors[] = $this->newRequiredError( + pht('Columns must have a name.')); + } + } + + $max_length = $object->getColumnMaximumByteLength('name'); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + $new_length = strlen($new_value); + if ($new_length > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Column names must not be longer than %s characters.', + new PhutilNumber($max_length)), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php new file mode 100644 index 0000000000..7606c72562 --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php @@ -0,0 +1,55 @@ +getStatus(); + } + + public function applyInternalEffects($object, $value) { + $object->setStatus($value); + } + + public function getTitle() { + $new = $this->getNewValue(); + + switch ($new) { + case PhabricatorProjectColumn::STATUS_ACTIVE: + return pht( + '%s unhid this column.', + $this->renderAuthor()); + case PhabricatorProjectColumn::STATUS_HIDDEN: + return pht( + '%s hid this column.', + $this->renderAuthor()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $map = array( + PhabricatorProjectColumn::STATUS_ACTIVE, + PhabricatorProjectColumn::STATUS_HIDDEN, + ); + $map = array_fuse($map); + + foreach ($xactions as $xaction) { + $value = $xaction->getNewValue(); + if (!isset($map[$value])) { + $errors[] = $this->newInvalidError( + pht( + 'Column status "%s" is unrecognized, valid statuses are: %s.', + $value, + implode(', ', array_keys($map))), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php b/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php new file mode 100644 index 0000000000..1473d3cabb --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php @@ -0,0 +1,4 @@ + Date: Thu, 14 Mar 2019 08:36:43 -0700 Subject: [PATCH 16/52] Allow triggers to be attached to and removed from workboard columns Summary: Depends on D20286. Ref T5474. Attaches triggers to columns and makes "Remove Trigger" work. (There's no "pick an existing named trigger from a list" UI yet, but I plan to add that at some point.) Test Plan: Attached and removed triggers, saw column UI update appropriately. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20287 --- src/__phutil_library_map__.php | 4 + .../PhabricatorProjectApplication.php | 4 + .../PhabricatorProjectBoardViewController.php | 120 +++++++++++++----- ...orProjectColumnRemoveTriggerController.php | 60 +++++++++ ...habricatorProjectTriggerEditController.php | 9 +- .../query/PhabricatorProjectColumnQuery.php | 2 +- .../storage/PhabricatorProjectTrigger.php | 20 ++- ...ricatorProjectColumnTriggerTransaction.php | 78 ++++++++++++ 8 files changed, 257 insertions(+), 40 deletions(-) create mode 100644 src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php create mode 100644 src/applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 110062e861..50df6a7201 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4070,6 +4070,7 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnPositionQuery' => 'applications/project/query/PhabricatorProjectColumnPositionQuery.php', 'PhabricatorProjectColumnPriorityOrder' => 'applications/project/order/PhabricatorProjectColumnPriorityOrder.php', 'PhabricatorProjectColumnQuery' => 'applications/project/query/PhabricatorProjectColumnQuery.php', + 'PhabricatorProjectColumnRemoveTriggerController' => 'applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php', 'PhabricatorProjectColumnSearchEngine' => 'applications/project/query/PhabricatorProjectColumnSearchEngine.php', 'PhabricatorProjectColumnStatusOrder' => 'applications/project/order/PhabricatorProjectColumnStatusOrder.php', 'PhabricatorProjectColumnStatusTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php', @@ -4078,6 +4079,7 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnTransactionEditor' => 'applications/project/editor/PhabricatorProjectColumnTransactionEditor.php', 'PhabricatorProjectColumnTransactionQuery' => 'applications/project/query/PhabricatorProjectColumnTransactionQuery.php', 'PhabricatorProjectColumnTransactionType' => 'applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php', + 'PhabricatorProjectColumnTriggerTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php', 'PhabricatorProjectConfigOptions' => 'applications/project/config/PhabricatorProjectConfigOptions.php', 'PhabricatorProjectConfiguredCustomField' => 'applications/project/customfield/PhabricatorProjectConfiguredCustomField.php', 'PhabricatorProjectController' => 'applications/project/controller/PhabricatorProjectController.php', @@ -10184,6 +10186,7 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnPositionQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorProjectColumnPriorityOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorProjectColumnRemoveTriggerController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnSearchEngine' => 'PhabricatorApplicationSearchEngine', 'PhabricatorProjectColumnStatusOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnStatusTransaction' => 'PhabricatorProjectColumnTransactionType', @@ -10192,6 +10195,7 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectColumnTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProjectColumnTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectColumnTriggerTransaction' => 'PhabricatorProjectColumnTransactionType', 'PhabricatorProjectConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorProjectConfiguredCustomField' => array( 'PhabricatorProjectStandardCustomField', diff --git a/src/applications/project/application/PhabricatorProjectApplication.php b/src/applications/project/application/PhabricatorProjectApplication.php index 192b40f6cd..46d7558f5b 100644 --- a/src/applications/project/application/PhabricatorProjectApplication.php +++ b/src/applications/project/application/PhabricatorProjectApplication.php @@ -89,6 +89,10 @@ final class PhabricatorProjectApplication extends PhabricatorApplication { 'background/' => 'PhabricatorProjectBoardBackgroundController', ), + 'column/' => array( + 'remove/(?P\d+)/' => + 'PhabricatorProjectColumnRemoveTriggerController', + ), 'trigger/' => array( $this->getQueryRoutePattern() => 'PhabricatorProjectTriggerListController', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 9b882c6ccc..dda21c0c43 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -574,6 +574,11 @@ final class PhabricatorProjectBoardViewController $column_menu = $this->buildColumnMenu($project, $column); $panel->addHeaderAction($column_menu); + if ($column->canHaveTrigger()) { + $trigger_menu = $this->buildTriggerMenu($column); + $panel->addHeaderAction($trigger_menu); + } + $count_tag = id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setColor(PHUITagView::COLOR_BLUE) @@ -1172,40 +1177,6 @@ final class PhabricatorProjectBoardViewController ->setWorkflow(true); } - if ($column->canHaveTrigger()) { - $column_items[] = id(new PhabricatorActionView()) - ->setType(PhabricatorActionView::TYPE_DIVIDER); - - $trigger = $column->getTrigger(); - if (!$trigger) { - $set_uri = $this->getApplicationURI( - new PhutilURI( - 'trigger/edit/', - array( - 'columnPHID' => $column->getPHID(), - ))); - - $column_items[] = id(new PhabricatorActionView()) - ->setIcon('fa-cogs') - ->setName(pht('New Trigger...')) - ->setHref($set_uri) - ->setDisabled(!$can_edit); - } else { - $column_items[] = id(new PhabricatorActionView()) - ->setIcon('fa-cogs') - ->setName(pht('View Trigger')) - ->setHref($trigger->getURI()) - ->setDisabled(!$can_edit); - } - - $column_items[] = id(new PhabricatorActionView()) - ->setIcon('fa-times') - ->setName(pht('Remove Trigger')) - ->setHref('#') - ->setWorkflow(true) - ->setDisabled(!$can_edit || !$trigger); - } - $column_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($column_items as $item) { @@ -1213,7 +1184,7 @@ final class PhabricatorProjectBoardViewController } $column_button = id(new PHUIIconView()) - ->setIcon('fa-caret-down') + ->setIcon('fa-pencil') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( @@ -1224,6 +1195,85 @@ final class PhabricatorProjectBoardViewController return $column_button; } + private function buildTriggerMenu(PhabricatorProjectColumn $column) { + $viewer = $this->getViewer(); + $trigger = $column->getTrigger(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $column, + PhabricatorPolicyCapability::CAN_EDIT); + + $trigger_items = array(); + if (!$trigger) { + $set_uri = $this->getApplicationURI( + new PhutilURI( + 'trigger/edit/', + array( + 'columnPHID' => $column->getPHID(), + ))); + + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cogs') + ->setName(pht('New Trigger...')) + ->setHref($set_uri) + ->setDisabled(!$can_edit); + } else { + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cogs') + ->setName(pht('View Trigger')) + ->setHref($trigger->getURI()) + ->setDisabled(!$can_edit); + } + + $remove_uri = $this->getApplicationURI( + new PhutilURI( + urisprintf( + 'column/remove/%d/', + $column->getID()))); + + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-times') + ->setName(pht('Remove Trigger')) + ->setHref($remove_uri) + ->setWorkflow(true) + ->setDisabled(!$can_edit || !$trigger); + + $trigger_menu = id(new PhabricatorActionListView()) + ->setUser($viewer); + foreach ($trigger_items as $item) { + $trigger_menu->addAction($item); + } + + if ($trigger) { + $trigger_icon = 'fa-cogs'; + } else { + $trigger_icon = 'fa-cogs grey'; + } + + if ($trigger) { + $trigger_tip = array( + pht('%s: %s', $trigger->getObjectName(), $trigger->getDisplayName()), + $trigger->getRulesDescription(), + ); + $trigger_tip = implode("\n", $trigger_tip); + } else { + $trigger_tip = pht('No column trigger.'); + } + + $trigger_button = id(new PHUIIconView()) + ->setIcon($trigger_icon) + ->setHref('#') + ->addSigil('boards-dropdown-menu') + ->addSigil('has-tooltip') + ->setMetadata( + array( + 'items' => hsprintf('%s', $trigger_menu), + 'tip' => $trigger_tip, + )); + + return $trigger_button; + } /** * Add current state parameters (like order and the visibility of hidden diff --git a/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php new file mode 100644 index 0000000000..5802449dcb --- /dev/null +++ b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php @@ -0,0 +1,60 @@ +getViewer(); + $id = $request->getURIData('id'); + + $column = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$column) { + return new Aphront404Response(); + } + + $done_uri = $column->getBoardURI(); + + if (!$column->getTriggerPHID()) { + return $this->newDialog() + ->setTitle(pht('No Trigger')) + ->appendParagraph( + pht('This column does not have a trigger.')) + ->addCancelButton($done_uri); + } + + if ($request->isFormPost()) { + $column_xactions = array(); + + $column_xactions[] = $column->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectColumnTriggerTransaction::TRANSACTIONTYPE) + ->setNewValue(null); + + $column_editor = $column->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true); + + $column_editor->applyTransactions($column, $column_xactions); + + return id(new AphrontRedirectResponse())->setURI($done_uri); + } + + $body = pht('Really remove the trigger from this column?'); + + return $this->newDialog() + ->setTitle(pht('Remove Trigger')) + ->appendParagraph($body) + ->addSubmitButton(pht('Remove Trigger')) + ->addCancelButton($done_uri); + } +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php index 86f75225d2..86f0844be3 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php @@ -93,13 +93,16 @@ final class PhabricatorProjectTriggerEditController if ($column) { $column_xactions = array(); - // TODO: Modularize column transactions so we can change the column - // trigger here. For now, this does nothing. + $column_xactions[] = $column->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectColumnTriggerTransaction::TRANSACTIONTYPE) + ->setNewValue($trigger->getPHID()); $column_editor = $column->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) - ->setContinueOnNoEffect(true); + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true); $column_editor->applyTransactions($column, $column_xactions); diff --git a/src/applications/project/query/PhabricatorProjectColumnQuery.php b/src/applications/project/query/PhabricatorProjectColumnQuery.php index 03169a7827..380dab5208 100644 --- a/src/applications/project/query/PhabricatorProjectColumnQuery.php +++ b/src/applications/project/query/PhabricatorProjectColumnQuery.php @@ -148,7 +148,7 @@ final class PhabricatorProjectColumnQuery $triggers = id(new PhabricatorProjectTriggerQuery()) ->setViewer($this->getViewer()) ->setParentQuery($this) - ->withPHIDs(array($this->getPHID())) + ->withPHIDs($trigger_phids) ->execute(); $triggers = mpull($triggers, null, 'getPHID'); } else { diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php index 7730d90529..1df8e935ec 100644 --- a/src/applications/project/storage/PhabricatorProjectTrigger.php +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -60,6 +60,11 @@ final class PhabricatorProjectTrigger return pht('Trigger %d', $this->getID()); } + public function getRulesDescription() { + // TODO: Summarize the trigger rules in human-readable text. + return pht('Does things.'); + } + /* -( PhabricatorApplicationTransactionInterface )------------------------- */ @@ -102,7 +107,20 @@ final class PhabricatorProjectTrigger public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { - $this->delete(); + + $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/xaction/column/PhabricatorProjectColumnTriggerTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php new file mode 100644 index 0000000000..78e2451bd1 --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php @@ -0,0 +1,78 @@ +getTriggerPHID(); + } + + public function applyInternalEffects($object, $value) { + $object->setTriggerPHID($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!$old) { + return pht( + '%s set the column trigger to %s.', + $this->renderAuthor(), + $this->renderNewHandle()); + } else if (!$new) { + return pht( + '%s removed the trigger for this column (was %s).', + $this->renderAuthor(), + $this->renderOldHandle()); + } else { + return pht( + '%s changed the trigger for this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldHandle(), + $this->renderNewHandle()); + } + } + + public function validateTransactions($object, array $xactions) { + $actor = $this->getActor(); + $errors = array(); + + foreach ($xactions as $xaction) { + $trigger_phid = $xaction->getNewValue(); + + // You can always remove a trigger. + if (!$trigger_phid) { + continue; + } + + // You can't put a trigger on a column that can't have triggers, like + // a backlog column or a proxy column. + if (!$object->canHaveTrigger()) { + $errors[] = $this->newInvalidError( + pht('This column can not have a trigger.'), + $xaction); + continue; + } + + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($actor) + ->withPHIDs(array($trigger_phid)) + ->execute(); + if (!$trigger) { + $errors[] = $this->newInvalidError( + pht( + 'Trigger "%s" is not a valid trigger, or you do not have '. + 'permission to view it.', + $trigger_phid), + $xaction); + continue; + } + } + + return $errors; + } + +} From 149f8cc9595a286854d3bb22624ed6c4da871ce6 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 14 Mar 2019 16:40:18 -0700 Subject: [PATCH 17/52] Hard code a "close task" action on every column Trigger Summary: Depends on D20287. Ref T5474. This hard-codes a storage value for every trigger, with a "Change status to " rule and two bogus rules. Rules may now apply transactions when cards are dropped. Test Plan: Dragged cards to a column with a trigger, saw them close. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20288 --- src/__phutil_library_map__.php | 12 ++ .../PhabricatorProjectBoardViewController.php | 1 + .../PhabricatorProjectMoveController.php | 14 ++ ...catorProjectTriggerCorruptionException.php | 4 + .../storage/PhabricatorProjectTrigger.php | 179 +++++++++++++++++- .../PhabricatorProjectTriggerInvalidRule.php | 22 +++ ...catorProjectTriggerManiphestStatusRule.php | 41 ++++ .../trigger/PhabricatorProjectTriggerRule.php | 89 +++++++++ .../PhabricatorProjectTriggerRuleRecord.php | 27 +++ .../PhabricatorProjectTriggerUnknownRule.php | 22 +++ 10 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php create mode 100644 src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php create mode 100644 src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php create mode 100644 src/applications/project/trigger/PhabricatorProjectTriggerRule.php create mode 100644 src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php create mode 100644 src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 50df6a7201..b042b017e2 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4174,16 +4174,22 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionType' => 'applications/project/xaction/PhabricatorProjectTransactionType.php', 'PhabricatorProjectTrigger' => 'applications/project/storage/PhabricatorProjectTrigger.php', 'PhabricatorProjectTriggerController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerController.php', + 'PhabricatorProjectTriggerCorruptionException' => 'applications/project/exception/PhabricatorProjectTriggerCorruptionException.php', 'PhabricatorProjectTriggerEditController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php', 'PhabricatorProjectTriggerEditor' => 'applications/project/editor/PhabricatorProjectTriggerEditor.php', + 'PhabricatorProjectTriggerInvalidRule' => 'applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php', 'PhabricatorProjectTriggerListController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerListController.php', + 'PhabricatorProjectTriggerManiphestStatusRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php', 'PhabricatorProjectTriggerNameTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php', 'PhabricatorProjectTriggerPHIDType' => 'applications/project/phid/PhabricatorProjectTriggerPHIDType.php', 'PhabricatorProjectTriggerQuery' => 'applications/project/query/PhabricatorProjectTriggerQuery.php', + 'PhabricatorProjectTriggerRule' => 'applications/project/trigger/PhabricatorProjectTriggerRule.php', + 'PhabricatorProjectTriggerRuleRecord' => 'applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php', 'PhabricatorProjectTriggerSearchEngine' => 'applications/project/query/PhabricatorProjectTriggerSearchEngine.php', 'PhabricatorProjectTriggerTransaction' => 'applications/project/storage/PhabricatorProjectTriggerTransaction.php', 'PhabricatorProjectTriggerTransactionQuery' => 'applications/project/query/PhabricatorProjectTriggerTransactionQuery.php', 'PhabricatorProjectTriggerTransactionType' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php', + 'PhabricatorProjectTriggerUnknownRule' => 'applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php', 'PhabricatorProjectTriggerViewController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php', 'PhabricatorProjectTypeTransaction' => 'applications/project/xaction/PhabricatorProjectTypeTransaction.php', 'PhabricatorProjectUIEventListener' => 'applications/project/events/PhabricatorProjectUIEventListener.php', @@ -10300,16 +10306,22 @@ phutil_register_library_map(array( 'PhabricatorDestructibleInterface', ), 'PhabricatorProjectTriggerController' => 'PhabricatorProjectController', + 'PhabricatorProjectTriggerCorruptionException' => 'Exception', 'PhabricatorProjectTriggerEditController' => 'PhabricatorProjectTriggerController', 'PhabricatorProjectTriggerEditor' => 'PhabricatorApplicationTransactionEditor', + 'PhabricatorProjectTriggerInvalidRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerListController' => 'PhabricatorProjectTriggerController', + 'PhabricatorProjectTriggerManiphestStatusRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerNameTransaction' => 'PhabricatorProjectTriggerTransactionType', 'PhabricatorProjectTriggerPHIDType' => 'PhabricatorPHIDType', 'PhabricatorProjectTriggerQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorProjectTriggerRule' => 'Phobject', + 'PhabricatorProjectTriggerRuleRecord' => 'Phobject', 'PhabricatorProjectTriggerSearchEngine' => 'PhabricatorApplicationSearchEngine', 'PhabricatorProjectTriggerTransaction' => 'PhabricatorModularTransaction', 'PhabricatorProjectTriggerTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProjectTriggerTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectTriggerUnknownRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerViewController' => 'PhabricatorProjectTriggerController', 'PhabricatorProjectTypeTransaction' => 'PhabricatorProjectTransactionType', 'PhabricatorProjectUIEventListener' => 'PhabricatorEventListener', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index dda21c0c43..3e702b981b 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -1270,6 +1270,7 @@ final class PhabricatorProjectBoardViewController array( 'items' => hsprintf('%s', $trigger_menu), 'tip' => $trigger_tip, + 'size' => 300, )); return $trigger_button; diff --git a/src/applications/project/controller/PhabricatorProjectMoveController.php b/src/applications/project/controller/PhabricatorProjectMoveController.php index 3cfd94894b..71588754c0 100644 --- a/src/applications/project/controller/PhabricatorProjectMoveController.php +++ b/src/applications/project/controller/PhabricatorProjectMoveController.php @@ -70,6 +70,7 @@ final class PhabricatorProjectMoveController $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array($project->getPHID())) + ->needTriggers(true) ->execute(); $columns = mpull($columns, null, 'getPHID'); @@ -110,6 +111,19 @@ final class PhabricatorProjectMoveController $xactions[] = $header_xaction; } + if ($column->canHaveTrigger()) { + $trigger = $column->getTrigger(); + if ($trigger) { + $trigger_xactions = $trigger->newDropTransactions( + $viewer, + $column, + $object); + foreach ($trigger_xactions as $trigger_xaction) { + $xactions[] = $trigger_xaction; + } + } + } + $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) ->setContinueOnMissingFields(true) diff --git a/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php b/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php new file mode 100644 index 0000000000..c235fe7357 --- /dev/null +++ b/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php @@ -0,0 +1,4 @@ +getID()); } - public function getRulesDescription() { - // TODO: Summarize the trigger rules in human-readable text. - return pht('Does things.'); + public function getTriggerRules() { + if ($this->triggerRules === null) { + + // TODO: Temporary hard-coded rule specification. + $rule_specifications = array( + array( + 'type' => 'status', + 'value' => ManiphestTaskStatus::getDefaultClosedStatus(), + ), + // This is an intentionally unknown rule. + array( + 'type' => 'quack', + 'value' => 'aaa', + ), + // This is an intentionally invalid rule. + array( + 'type' => 'status', + 'value' => 'quack', + ), + ); + + // NOTE: We're trying to preserve the database state in the rule + // structure, even if it includes rule types we don't have 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. + + // 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($rule_specifications)) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ("%s") has a corrupt ruleset: expected a list of '. + 'rule specifications, found "%s".', + $this->getPHID(), + phutil_describe_type($rule_specifications))); + } + + $trigger_rules = array(); + foreach ($rule_specifications as $key => $rule) { + if (!is_array($rule)) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ("%s") has a corrupt ruleset: rule (with key "%s") '. + 'should be a rule specification, but is actually "%s".', + $this->getPHID(), + $key, + phutil_describe_type($rule))); + } + + try { + PhutilTypeSpec::checkMap( + $rule, + array( + 'type' => 'string', + 'value' => 'wild', + )); + } catch (PhutilTypeCheckException $ex) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ("%s") has a corrupt ruleset: rule (with key "%s") '. + 'is not a valid rule specification: %s', + $this->getPHID(), + $key, + $ex->getMessage())); + } + + $record = id(new PhabricatorProjectTriggerRuleRecord()) + ->setType(idx($rule, 'type')) + ->setValue(idx($rule, 'value')); + + if (!isset($rule_map[$record->getType()])) { + $rule = new PhabricatorProjectTriggerUnknownRule(); + } else { + $rule = clone $rule_map[$record->getType()]; + } + + try { + $rule->setRecord($record); + } catch (Exception $ex) { + $rule = id(new PhabricatorProjectTriggerInvalidRule()) + ->setRecord($record); + } + + $trigger_rules[] = $rule; + } + + $this->triggerRules = $trigger_rules; + } + + return $this->triggerRules; } + 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 )------------------------- */ diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php new file mode 100644 index 0000000000..55e91e9136 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php @@ -0,0 +1,22 @@ +getRecord()->getType()); + } + + protected function assertValidRuleValue($value) { + return; + } + + protected function newDropTransactions($object, $value) { + return array(); + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php new file mode 100644 index 0000000000..ef630f89d9 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php @@ -0,0 +1,41 @@ +getValue(); + + return pht( + 'Changes status to "%s".', + ManiphestTaskStatus::getTaskStatusName($value)); + } + + 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), + ); + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php new file mode 100644 index 0000000000..b311c05ff8 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -0,0 +1,89 @@ +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 protected function assertValidRuleValue($value); + abstract protected function newDropTransactions($object, $value); + + 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(); + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php b/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php new file mode 100644 index 0000000000..da36d9a4d8 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php @@ -0,0 +1,27 @@ +type = $type; + return $this; + } + + public function getType() { + return $this->type; + } + + public function setValue($value) { + $this->value = $value; + return $this; + } + + public function getValue() { + return $this->value; + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php new file mode 100644 index 0000000000..881a6652f4 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php @@ -0,0 +1,22 @@ +getRecord()->getType()); + } + + protected function assertValidRuleValue($value) { + return; + } + + protected function newDropTransactions($object, $value) { + return array(); + } + +} From 5dca1569b5772996333a5ecc091bc534016706e0 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 19 Mar 2019 15:27:21 -0700 Subject: [PATCH 18/52] Preview the effects of a drag-and-drop operation on workboards Summary: Ref T10335. Ref T5474. When you drag-and-drop a card on a workboard, show a UI hint which lists all the things that the operation will do. This shows: column moves; changes because of dragging a card to a different header; and changes which will be caused by triggers. Not implemented here: - Actions are currently shown even if they have no effect. For example, if you drag a "Normal" task to a different column, it says "Change priority to Normal.". I plan to hide actions which have no effect, but figuring this out is a little bit tricky. - I'd like to make "trigger effects" vs "non-trigger effects" a little more clear in the future, probably. Test Plan: Dragged stuff between columns and headers, and into columns with triggers. Got appropriate preview text hints previewing what the action would do in the UI. (This is tricky to take a screenshot of since it only shows up while the mouse cursor is down.) Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T10335, T5474 Differential Revision: https://secure.phabricator.com/D20299 --- resources/celerity/map.php | 113 ++++++------ src/__phutil_library_map__.php | 2 + .../PhabricatorProjectBoardViewController.php | 16 +- .../icon/PhabricatorProjectDropEffect.php | 45 +++++ .../order/PhabricatorProjectColumnHeader.php | 11 ++ .../order/PhabricatorProjectColumnOrder.php | 4 + .../PhabricatorProjectColumnOwnerOrder.php | 15 ++ .../PhabricatorProjectColumnPriorityOrder.php | 11 +- .../PhabricatorProjectColumnStatusOrder.php | 11 +- .../storage/PhabricatorProjectColumn.php | 35 ++++ .../storage/PhabricatorProjectTrigger.php | 13 ++ .../PhabricatorProjectTriggerInvalidRule.php | 4 + ...catorProjectTriggerManiphestStatusRule.php | 17 ++ .../trigger/PhabricatorProjectTriggerRule.php | 9 + .../PhabricatorProjectTriggerUnknownRule.php | 4 + .../css/phui/workboards/phui-workpanel.css | 36 ++++ .../js/application/projects/WorkboardBoard.js | 172 ++++++++++++++---- .../application/projects/WorkboardColumn.js | 11 ++ .../projects/WorkboardDropEffect.js | 35 ++++ .../projects/WorkboardHeaderTemplate.js | 3 +- .../projects/behavior-project-boards.js | 35 +++- webroot/rsrc/js/core/DraggableList.js | 21 ++- 22 files changed, 522 insertions(+), 101 deletions(-) create mode 100644 src/applications/project/icon/PhabricatorProjectDropEffect.php create mode 100644 webroot/rsrc/js/application/projects/WorkboardDropEffect.js diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 08bcbff8bf..4d095ad2b1 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -10,7 +10,7 @@ return array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', 'core.pkg.css' => 'b797945d', - 'core.pkg.js' => 'f9c2509b', + 'core.pkg.js' => 'eaca003c', 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', 'diffusion.pkg.css' => '42c75c37', @@ -178,7 +178,7 @@ return array( 'rsrc/css/phui/workboards/phui-workboard-color.css' => 'e86de308', 'rsrc/css/phui/workboards/phui-workboard.css' => '74fc9d98', 'rsrc/css/phui/workboards/phui-workcard.css' => '9e9eb0df', - 'rsrc/css/phui/workboards/phui-workpanel.css' => 'c5b408ad', + 'rsrc/css/phui/workboards/phui-workpanel.css' => 'e5461a51', 'rsrc/css/sprite-login.css' => '18b368a6', 'rsrc/css/sprite-tokens.css' => 'f1896dc5', 'rsrc/css/syntax/syntax-default.css' => '055fc231', @@ -408,15 +408,16 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '9d59f098', + 'rsrc/js/application/projects/WorkboardBoard.js' => 'ba6e36b0', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', - 'rsrc/js/application/projects/WorkboardColumn.js' => 'ec5c5ce0', + 'rsrc/js/application/projects/WorkboardColumn.js' => 'c344eb3c', 'rsrc/js/application/projects/WorkboardController.js' => '42c7a5a7', + 'rsrc/js/application/projects/WorkboardDropEffect.js' => '101121be', 'rsrc/js/application/projects/WorkboardHeader.js' => '111bfd2d', - 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'b65351bd', + 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'ebe83a6b', 'rsrc/js/application/projects/WorkboardOrderTemplate.js' => '03e8891f', - 'rsrc/js/application/projects/behavior-project-boards.js' => '412af9d4', + 'rsrc/js/application/projects/behavior-project-boards.js' => 'cd7c9d4f', 'rsrc/js/application/projects/behavior-project-create.js' => '34c53422', 'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68', @@ -437,7 +438,7 @@ return array( 'rsrc/js/application/uiexample/notification-example.js' => '29819b75', 'rsrc/js/core/Busy.js' => '5202e831', 'rsrc/js/core/DragAndDropFileUpload.js' => '4370900d', - 'rsrc/js/core/DraggableList.js' => '8bc7d797', + 'rsrc/js/core/DraggableList.js' => 'c9ad6f70', 'rsrc/js/core/Favicon.js' => '7930776a', 'rsrc/js/core/FileUpload.js' => 'ab85e184', 'rsrc/js/core/Hovercard.js' => '074f0783', @@ -657,7 +658,7 @@ return array( 'javelin-behavior-phuix-example' => 'c2c500a7', 'javelin-behavior-policy-control' => '0eaa33a9', 'javelin-behavior-policy-rule-editor' => '9347f172', - 'javelin-behavior-project-boards' => '412af9d4', + 'javelin-behavior-project-boards' => 'cd7c9d4f', 'javelin-behavior-project-create' => '34c53422', 'javelin-behavior-quicksand-blacklist' => '5a6f6a06', 'javelin-behavior-read-only-warning' => 'b9109f8f', @@ -729,13 +730,14 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '9d59f098', + 'javelin-workboard-board' => 'ba6e36b0', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', - 'javelin-workboard-column' => 'ec5c5ce0', + 'javelin-workboard-column' => 'c344eb3c', 'javelin-workboard-controller' => '42c7a5a7', + 'javelin-workboard-drop-effect' => '101121be', 'javelin-workboard-header' => '111bfd2d', - 'javelin-workboard-header-template' => 'b65351bd', + 'javelin-workboard-header-template' => 'ebe83a6b', 'javelin-workboard-order-template' => '03e8891f', 'javelin-workflow' => '958e9045', 'maniphest-report-css' => '3d53188b', @@ -761,7 +763,7 @@ return array( 'phabricator-diff-changeset-list' => '04023d82', 'phabricator-diff-inline' => 'a4a14a94', 'phabricator-drag-and-drop-file-upload' => '4370900d', - 'phabricator-draggable-list' => '8bc7d797', + 'phabricator-draggable-list' => 'c9ad6f70', 'phabricator-fatal-config-template-css' => '20babf50', 'phabricator-favicon' => '7930776a', 'phabricator-feed-css' => 'd8b6e3f8', @@ -860,7 +862,7 @@ return array( 'phui-workboard-color-css' => 'e86de308', 'phui-workboard-view-css' => '74fc9d98', 'phui-workcard-view-css' => '9e9eb0df', - 'phui-workpanel-view-css' => 'c5b408ad', + 'phui-workpanel-view-css' => 'e5461a51', 'phuix-action-list-view' => 'c68f183f', 'phuix-action-view' => 'aaa08f3b', 'phuix-autocomplete' => '8f139ef0', @@ -1001,6 +1003,10 @@ return array( 'javelin-workflow', 'phuix-icon-view', ), + '101121be' => array( + 'javelin-install', + 'javelin-dom', + ), '111bfd2d' => array( 'javelin-install', ), @@ -1227,15 +1233,6 @@ return array( 'javelin-behavior', 'javelin-uri', ), - '412af9d4' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-util', - 'javelin-vector', - 'javelin-stratcom', - 'javelin-workflow', - 'javelin-workboard-controller', - ), '4234f572' => array( 'syntax-default-css', ), @@ -1593,14 +1590,6 @@ return array( 'javelin-dom', 'javelin-typeahead-normalizer', ), - '8bc7d797' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-stratcom', - 'javelin-util', - 'javelin-vector', - 'javelin-magical-init', - ), '8c2ed2bf' => array( 'javelin-behavior', 'javelin-dom', @@ -1725,18 +1714,6 @@ return array( 'javelin-uri', 'phabricator-textareautils', ), - '9d59f098' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), '9f081f05' => array( 'javelin-behavior', 'javelin-dom', @@ -1885,9 +1862,6 @@ return array( 'javelin-stratcom', 'javelin-dom', ), - 'b65351bd' => array( - 'javelin-install', - ), 'b7b73831' => array( 'javelin-behavior', 'javelin-dom', @@ -1906,6 +1880,18 @@ return array( 'javelin-uri', 'phabricator-notification', ), + 'ba6e36b0' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), 'bdce4d78' => array( 'javelin-install', 'javelin-util', @@ -1930,15 +1916,17 @@ return array( 'javelin-dom', 'phuix-button-view', ), + 'c344eb3c' => array( + 'javelin-install', + 'javelin-workboard-card', + 'javelin-workboard-header', + ), 'c3703a16' => array( 'javelin-behavior', 'javelin-aphlict', 'phabricator-phtize', 'javelin-dom', ), - 'c5b408ad' => array( - 'phui-workcard-view-css', - ), 'c687e867' => array( 'javelin-behavior', 'javelin-dom', @@ -1978,6 +1966,24 @@ return array( 'javelin-util', 'phabricator-keyboard-shortcut-manager', ), + 'c9ad6f70' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-stratcom', + 'javelin-util', + 'javelin-vector', + 'javelin-magical-init', + ), + 'cd7c9d4f' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'javelin-vector', + 'javelin-stratcom', + 'javelin-workflow', + 'javelin-workboard-controller', + 'javelin-workboard-drop-effect', + ), 'cf32921f' => array( 'javelin-behavior', 'javelin-dom', @@ -2038,6 +2044,9 @@ return array( 'javelin-dom', 'javelin-history', ), + 'e5461a51' => array( + 'phui-workcard-view-css', + ), 'e562708c' => array( 'javelin-install', ), @@ -2068,14 +2077,12 @@ return array( 'javelin-install', 'javelin-event', ), + 'ebe83a6b' => array( + 'javelin-install', + ), 'ec4e31c0' => array( 'phui-timeline-view-css', ), - 'ec5c5ce0' => array( - 'javelin-install', - 'javelin-workboard-card', - 'javelin-workboard-header', - ), 'ee77366f' => array( 'aphront-dialog-view-css', ), diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index b042b017e2..ebe7c2b9c3 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4094,6 +4094,7 @@ phutil_register_library_map(array( 'PhabricatorProjectDefaultController' => 'applications/project/controller/PhabricatorProjectDefaultController.php', 'PhabricatorProjectDescriptionField' => 'applications/project/customfield/PhabricatorProjectDescriptionField.php', 'PhabricatorProjectDetailsProfileMenuItem' => 'applications/project/menuitem/PhabricatorProjectDetailsProfileMenuItem.php', + 'PhabricatorProjectDropEffect' => 'applications/project/icon/PhabricatorProjectDropEffect.php', 'PhabricatorProjectEditController' => 'applications/project/controller/PhabricatorProjectEditController.php', 'PhabricatorProjectEditEngine' => 'applications/project/engine/PhabricatorProjectEditEngine.php', 'PhabricatorProjectEditPictureController' => 'applications/project/controller/PhabricatorProjectEditPictureController.php', @@ -10219,6 +10220,7 @@ phutil_register_library_map(array( 'PhabricatorProjectDefaultController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectDescriptionField' => 'PhabricatorProjectStandardCustomField', 'PhabricatorProjectDetailsProfileMenuItem' => 'PhabricatorProfileMenuItem', + 'PhabricatorProjectDropEffect' => 'Phobject', 'PhabricatorProjectEditController' => 'PhabricatorProjectController', 'PhabricatorProjectEditEngine' => 'PhabricatorEditEngine', 'PhabricatorProjectEditPictureController' => 'PhabricatorProjectController', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 3e702b981b..41981a5522 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -540,8 +540,8 @@ final class PhabricatorProjectBoardViewController ->setExcludedProjectPHIDs($select_phids); $templates = array(); - $column_maps = array(); $all_tasks = array(); + $column_templates = array(); foreach ($visible_columns as $column_phid => $column) { $column_tasks = $column_phids[$column_phid]; @@ -606,18 +606,28 @@ final class PhabricatorProjectBoardViewController 'pointLimit' => $column->getPointLimit(), )); + $card_phids = array(); foreach ($column_tasks as $task) { $object_phid = $task->getPHID(); $card = $rendering_engine->renderCard($object_phid); $templates[$object_phid] = hsprintf('%s', $card->getItem()); - $column_maps[$column_phid][] = $object_phid; + $card_phids[] = $object_phid; $all_tasks[$object_phid] = $task; } $panel->setCards($cards); $board->addPanel($panel); + + $drop_effects = $column->getDropEffects(); + $drop_effects = mpull($drop_effects, 'toDictionary'); + + $column_templates[] = array( + 'columnPHID' => $column_phid, + 'effects' => $drop_effects, + 'cardPHIDs' => $card_phids, + ); } $order_key = $this->sortKey; @@ -661,9 +671,9 @@ final class PhabricatorProjectBoardViewController 'headers' => $headers, 'headerKeys' => $header_keys, 'templateMap' => $templates, - 'columnMaps' => $column_maps, 'orderMaps' => $vector_map, 'propertyMaps' => $properties, + 'columnTemplates' => $column_templates, 'boardID' => $board_id, 'projectPHID' => $project->getPHID(), diff --git a/src/applications/project/icon/PhabricatorProjectDropEffect.php b/src/applications/project/icon/PhabricatorProjectDropEffect.php new file mode 100644 index 0000000000..33145eb039 --- /dev/null +++ b/src/applications/project/icon/PhabricatorProjectDropEffect.php @@ -0,0 +1,45 @@ +icon = $icon; + return $this; + } + + public function getIcon() { + return $this->icon; + } + + public function setColor($color) { + $this->color = $color; + return $this; + } + + public function getColor() { + return $this->color; + } + + public function setContent($content) { + $this->content = $content; + return $this; + } + + public function getContent() { + return $this->content; + } + + public function toDictionary() { + return array( + 'icon' => $this->getIcon(), + 'color' => $this->getColor(), + 'content' => hsprintf('%s', $this->getContent()), + ); + } + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnHeader.php b/src/applications/project/order/PhabricatorProjectColumnHeader.php index 24d1e5c5ec..898d9b0222 100644 --- a/src/applications/project/order/PhabricatorProjectColumnHeader.php +++ b/src/applications/project/order/PhabricatorProjectColumnHeader.php @@ -9,6 +9,7 @@ final class PhabricatorProjectColumnHeader private $name; private $icon; private $editProperties; + private $dropEffects = array(); public function setOrderKey($order_key) { $this->orderKey = $order_key; @@ -64,6 +65,15 @@ final class PhabricatorProjectColumnHeader return $this->editProperties; } + public function addDropEffect(PhabricatorProjectDropEffect $effect) { + $this->dropEffects[] = $effect; + return $this; + } + + public function getDropEffects() { + return $this->dropEffects; + } + public function toDictionary() { return array( 'order' => $this->getOrderKey(), @@ -71,6 +81,7 @@ final class PhabricatorProjectColumnHeader 'template' => hsprintf('%s', $this->newView()), 'vector' => $this->getSortVector(), 'editProperties' => $this->getEditProperties(), + 'effects' => mpull($this->getDropEffects(), 'toDictionary'), ); } diff --git a/src/applications/project/order/PhabricatorProjectColumnOrder.php b/src/applications/project/order/PhabricatorProjectColumnOrder.php index c2da400fb2..430d9ef472 100644 --- a/src/applications/project/order/PhabricatorProjectColumnOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnOrder.php @@ -196,6 +196,10 @@ abstract class PhabricatorProjectColumnOrder ->setOrderKey($this->getColumnOrderKey()); } + final protected function newEffect() { + return new PhabricatorProjectDropEffect(); + } + final public function toDictionary() { return array( 'orderKey' => $this->getColumnOrderKey(), diff --git a/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php b/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php index 336411bac5..920e87c9b7 100644 --- a/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php @@ -122,16 +122,23 @@ final class PhabricatorProjectColumnOwnerOrder $header_key = $this->newHeaderKeyForOwnerPHID($owner_phid); $owner_image = null; + $effect_content = null; if ($owner_phid === null) { $owner = null; $sort_vector = $this->newSortVectorForUnowned(); $owner_name = pht('Not Assigned'); + + $effect_content = pht('Remove task assignee.'); } else { $owner = idx($owner_users, $owner_phid); if ($owner) { $sort_vector = $this->newSortVectorForOwner($owner); $owner_name = $owner->getUsername(); $owner_image = $owner->getProfileImageURI(); + + $effect_content = pht( + 'Assign task to %s.', + phutil_tag('strong', array(), $owner_name)); } else { $sort_vector = $this->newSortVectorForOwnerPHID($owner_phid); $owner_name = pht('Unknown User ("%s")', $owner_phid); @@ -159,6 +166,14 @@ final class PhabricatorProjectColumnOwnerOrder 'value' => $owner_phid, )); + if ($effect_content !== null) { + $header->addDropEffect( + $this->newEffect() + ->setIcon($owner_icon) + ->setColor($owner_color) + ->setContent($effect_content)); + } + $headers[] = $header; } diff --git a/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php b/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php index 10fcafad76..8cffab91ae 100644 --- a/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php @@ -65,6 +65,14 @@ final class PhabricatorProjectColumnPriorityOrder $icon_view = id(new PHUIIconView()) ->setIcon($priority_icon, $priority_color); + $drop_effect = $this->newEffect() + ->setIcon($priority_icon) + ->setColor($priority_color) + ->setContent( + pht( + 'Change priority to %s.', + phutil_tag('strong', array(), $priority_name))); + $header = $this->newHeader() ->setHeaderKey($header_key) ->setSortVector($sort_vector) @@ -73,7 +81,8 @@ final class PhabricatorProjectColumnPriorityOrder ->setEditProperties( array( 'value' => (int)$priority, - )); + )) + ->addDropEffect($drop_effect); $headers[] = $header; } diff --git a/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php b/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php index e58d05f655..419d7062c7 100644 --- a/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php @@ -72,6 +72,14 @@ final class PhabricatorProjectColumnStatusOrder $icon_view = id(new PHUIIconView()) ->setIcon($status_icon, $status_color); + $drop_effect = $this->newEffect() + ->setIcon($status_icon) + ->setColor($status_color) + ->setContent( + pht( + 'Change status to %s.', + phutil_tag('strong', array(), $status_name))); + $header = $this->newHeader() ->setHeaderKey($header_key) ->setSortVector($sort_vector) @@ -80,7 +88,8 @@ final class PhabricatorProjectColumnStatusOrder ->setEditProperties( array( 'value' => $status_key, - )); + )) + ->addDropEffect($drop_effect); $headers[] = $header; } diff --git a/src/applications/project/storage/PhabricatorProjectColumn.php b/src/applications/project/storage/PhabricatorProjectColumn.php index 5eb9826309..731c2a15fb 100644 --- a/src/applications/project/storage/PhabricatorProjectColumn.php +++ b/src/applications/project/storage/PhabricatorProjectColumn.php @@ -218,6 +218,41 @@ final class PhabricatorProjectColumn $this->getProject()->getID()); } + public function getDropEffects() { + $effects = array(); + + $proxy = $this->getProxy(); + if ($proxy && $proxy->isMilestone()) { + $effects[] = id(new PhabricatorProjectDropEffect()) + ->setIcon($proxy->getProxyColumnIcon()) + ->setColor('violet') + ->setContent( + pht( + 'Move to milestone %s.', + phutil_tag('strong', array(), $this->getDisplayName()))); + } else { + $effects[] = id(new PhabricatorProjectDropEffect()) + ->setIcon('fa-columns') + ->setColor('blue') + ->setContent( + pht( + 'Move to column %s.', + phutil_tag('strong', array(), $this->getDisplayName()))); + } + + + if ($this->canHaveTrigger()) { + $trigger = $this->getTrigger(); + if ($trigger) { + foreach ($trigger->getDropEffects() as $trigger_effect) { + $effects[] = $trigger_effect; + } + } + } + + return $effects; + } + /* -( PhabricatorConduitResultInterface )---------------------------------- */ diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php index 415d3dbb9e..b02e1ce107 100644 --- a/src/applications/project/storage/PhabricatorProjectTrigger.php +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -170,6 +170,19 @@ final class PhabricatorProjectTrigger return $this->triggerRules; } + 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) { diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php index 55e91e9136..4157ec8e9a 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php @@ -19,4 +19,8 @@ final class PhabricatorProjectTriggerInvalidRule return array(); } + protected function newDropEffects($value) { + return array(); + } + } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php index ef630f89d9..575f51cd2b 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php @@ -38,4 +38,21 @@ final class PhabricatorProjectTriggerManiphestStatusRule ); } + 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) + ->setContent($content), + ); + } + } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php index b311c05ff8..6caf9ee0be 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -40,6 +40,7 @@ abstract class PhabricatorProjectTriggerRule abstract public function getDescription(); abstract protected function assertValidRuleValue($value); abstract protected function newDropTransactions($object, $value); + abstract protected function newDropEffects($value); final public function getDropTransactions($object, $value) { return $this->newDropTransactions($object, $value); @@ -86,4 +87,12 @@ abstract class PhabricatorProjectTriggerRule return $this->getObject()->getApplicationTransactionTemplate(); } + final public function getDropEffects() { + return $this->newDropEffects($this->getValue()); + } + + final protected function newEffect() { + return new PhabricatorProjectDropEffect(); + } + } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php index 881a6652f4..8d796686de 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php @@ -19,4 +19,8 @@ final class PhabricatorProjectTriggerUnknownRule return array(); } + protected function newDropEffects($value) { + return array(); + } + } diff --git a/webroot/rsrc/css/phui/workboards/phui-workpanel.css b/webroot/rsrc/css/phui/workboards/phui-workpanel.css index 95db8021ef..ce0e7885a8 100644 --- a/webroot/rsrc/css/phui/workboards/phui-workpanel.css +++ b/webroot/rsrc/css/phui/workboards/phui-workpanel.css @@ -178,3 +178,39 @@ margin-left: 36px; overflow: hidden; } + +.workboard-drop-preview { + pointer-events: none; + position: absolute; + bottom: 12px; + right: 12px; + width: 300px; + border-radius: 3px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + border: 1px solid {$lightblueborder}; + padding: 4px 0; +} + +.workboard-drop-preview:hover { + opacity: 0.25; +} + +.workboard-drop-preview li { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 4px 8px; + color: {$greytext}; +} + +.workboard-drop-preview li .phui-icon-view { + position: relative; + display: inline-block; + text-align: center; + width: 24px; + height: 18px; + padding-top: 6px; + border-radius: 3px; + background: {$bluebackground}; + margin-right: 6px; +} diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index fa10b2a180..6fab227c84 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -39,6 +39,8 @@ JX.install('WorkboardBoard', { _columns: null, _headers: null, _cards: null, + _dropPreviewNode: null, + _dropPreviewListNode: null, getRoot: function() { return this._root; @@ -180,6 +182,8 @@ JX.install('WorkboardBoard', { list.setCompareOnReorder(true); } + list.setTargetChangeHandler(JX.bind(this, this._didChangeDropTarget)); + list.listen('didDrop', JX.bind(this, this._onmovecard, list)); lists.push(list); @@ -190,23 +194,89 @@ JX.install('WorkboardBoard', { } }, + _didChangeDropTarget: function(src_list, src_node, dst_list, dst_node) { + var node = this._getDropPreviewNode(); + + if (!dst_list) { + // The card is being dragged into a dead area, like the left menu. + JX.DOM.remove(node); + return; + } + + if (dst_node === false) { + // The card is being dragged over itself, so dropping it won't + // affect anything. + JX.DOM.remove(node); + return; + } + + var src_phid = JX.Stratcom.getData(src_list.getRootNode()).columnPHID; + var dst_phid = JX.Stratcom.getData(dst_list.getRootNode()).columnPHID; + + var src_column = this.getColumn(src_phid); + var dst_column = this.getColumn(dst_phid); + + var effects = []; + + if (src_column !== dst_column) { + effects = effects.concat(dst_column.getDropEffects()); + } + + var context = this._getDropContext(dst_node); + if (context.headerKey) { + var header = this.getHeaderTemplate(context.headerKey); + effects = effects.concat(header.getDropEffects()); + } + + if (!effects.length) { + JX.DOM.remove(node); + return; + } + + var items = []; + for (var ii = 0; ii < effects.length; ii++) { + var effect = effects[ii]; + items.push(effect.newNode()); + } + + JX.DOM.setContent(this._getDropPreviewListNode(), items); + + document.body.appendChild(node); + }, + + _getDropPreviewNode: function() { + if (!this._dropPreviewNode) { + var attributes = { + className: 'workboard-drop-preview' + }; + + var content = [ + this._getDropPreviewListNode() + ]; + + this._dropPreviewNode = JX.$N('div', attributes, content); + } + + return this._dropPreviewNode; + }, + + _getDropPreviewListNode: function() { + if (!this._dropPreviewListNode) { + var attributes = {}; + this._dropPreviewListNode = JX.$N('ul', attributes); + } + + return this._dropPreviewListNode; + }, + _findCardsInColumn: function(column_node) { return JX.DOM.scry(column_node, 'li', 'project-card'); }, - _onmovecard: function(list, item, after_node, src_list) { - list.lock(); - JX.DOM.alterClass(item, 'drag-sending', true); - - var src_phid = JX.Stratcom.getData(src_list.getRootNode()).columnPHID; - var dst_phid = JX.Stratcom.getData(list.getRootNode()).columnPHID; - - var item_phid = JX.Stratcom.getData(item).objectPHID; - var data = { - objectPHID: item_phid, - columnPHID: dst_phid, - order: this.getOrder() - }; + _getDropContext: function(after_node, item) { + var header_key; + var before_phid; + var after_phid; // We're going to send an "afterPHID" and a "beforePHID" if the card // was dropped immediately adjacent to another card. If a card was @@ -231,26 +301,28 @@ JX.install('WorkboardBoard', { if (after_data) { if (after_data.objectPHID) { - data.afterPHID = after_data.objectPHID; + after_phid = after_data.objectPHID; } } - var before_data; - var before_card = item.nextSibling; - while (before_card) { - before_data = JX.Stratcom.getData(before_card); - if (before_data.objectPHID) { - break; + if (item) { + var before_data; + var before_card = item.nextSibling; + while (before_card) { + before_data = JX.Stratcom.getData(before_card); + if (before_data.objectPHID) { + break; + } + if (before_data.headerKey) { + break; + } + before_card = before_card.nextSibling; } - if (before_data.headerKey) { - break; - } - before_card = before_card.nextSibling; - } - if (before_data) { - if (before_data.objectPHID) { - data.beforePHID = before_data.objectPHID; + if (before_data) { + if (before_data.objectPHID) { + before_phid = before_data.objectPHID; + } } } @@ -265,12 +337,44 @@ JX.install('WorkboardBoard', { } if (header_data) { - var header_key = header_data.headerKey; - if (header_key) { - var properties = this.getHeaderTemplate(header_key) - .getEditProperties(); - data.header = JX.JSON.stringify(properties); - } + header_key = header_data.headerKey; + } + + return { + headerKey: header_key, + afterPHID: after_phid, + beforePHID: before_phid + }; + }, + + _onmovecard: function(list, item, after_node, src_list) { + list.lock(); + JX.DOM.alterClass(item, 'drag-sending', true); + + var src_phid = JX.Stratcom.getData(src_list.getRootNode()).columnPHID; + var dst_phid = JX.Stratcom.getData(list.getRootNode()).columnPHID; + + var item_phid = JX.Stratcom.getData(item).objectPHID; + var data = { + objectPHID: item_phid, + columnPHID: dst_phid, + order: this.getOrder() + }; + + var context = this._getDropContext(after_node); + + if (context.afterPHID) { + data.afterPHID = context.afterPHID; + } + + if (context.beforePHID) { + data.beforePHID = context.beforePHID; + } + + if (context.headerKey) { + var properties = this.getHeaderTemplate(context.headerKey) + .getEditProperties(); + data.header = JX.JSON.stringify(properties); } var visible_phids = []; diff --git a/webroot/rsrc/js/application/projects/WorkboardColumn.js b/webroot/rsrc/js/application/projects/WorkboardColumn.js index 709c52016a..593afea776 100644 --- a/webroot/rsrc/js/application/projects/WorkboardColumn.js +++ b/webroot/rsrc/js/application/projects/WorkboardColumn.js @@ -25,6 +25,7 @@ JX.install('WorkboardColumn', { this._headers = {}; this._objects = []; this._naturalOrder = []; + this._dropEffects = []; }, members: { @@ -40,6 +41,7 @@ JX.install('WorkboardColumn', { _pointsContentNode: null, _dirty: true, _objects: null, + _dropEffects: null, getPHID: function() { return this._phid; @@ -71,6 +73,15 @@ JX.install('WorkboardColumn', { return this; }, + setDropEffects: function(effects) { + this._dropEffects = effects; + return this; + }, + + getDropEffects: function() { + return this._dropEffects; + }, + getPointsNode: function() { return this._pointsNode; }, diff --git a/webroot/rsrc/js/application/projects/WorkboardDropEffect.js b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js new file mode 100644 index 0000000000..ecd18d0015 --- /dev/null +++ b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js @@ -0,0 +1,35 @@ +/** + * @provides javelin-workboard-drop-effect + * @requires javelin-install + * javelin-dom + * @javelin + */ + +JX.install('WorkboardDropEffect', { + + properties: { + icon: null, + color: null, + content: null + }, + + statics: { + newFromDictionary: function(map) { + return new JX.WorkboardDropEffect() + .setIcon(map.icon) + .setColor(map.color) + .setContent(JX.$H(map.content)); + } + }, + + members: { + newNode: function() { + var icon = new JX.PHUIXIconView() + .setIcon(this.getIcon()) + .setColor(this.getColor()) + .getNode(); + + return JX.$N('li', {}, [icon, this.getContent()]); + } + } +}); diff --git a/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js b/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js index 8376359270..d64a56dd29 100644 --- a/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js +++ b/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js @@ -14,7 +14,8 @@ JX.install('WorkboardHeaderTemplate', { template: null, order: null, vector: null, - editProperties: null + editProperties: null, + dropEffects: [] }, members: { diff --git a/webroot/rsrc/js/application/projects/behavior-project-boards.js b/webroot/rsrc/js/application/projects/behavior-project-boards.js index 3aa43722c4..f25599391f 100644 --- a/webroot/rsrc/js/application/projects/behavior-project-boards.js +++ b/webroot/rsrc/js/application/projects/behavior-project-boards.js @@ -7,6 +7,7 @@ * javelin-stratcom * javelin-workflow * javelin-workboard-controller + * javelin-workboard-drop-effect */ JX.behavior('project-boards', function(config, statics) { @@ -88,12 +89,24 @@ JX.behavior('project-boards', function(config, statics) { } var ii; - var column_maps = config.columnMaps; - for (var column_phid in column_maps) { - var column = board.getColumn(column_phid); - var column_map = column_maps[column_phid]; - for (ii = 0; ii < column_map.length; ii++) { - column.newCard(column_map[ii]); + var jj; + var effects; + + for (ii = 0; ii < config.columnTemplates.length; ii++) { + var spec = config.columnTemplates[ii]; + + var column = board.getColumn(spec.columnPHID); + + effects = []; + for (jj = 0; jj < spec.effects.length; jj++) { + effects.push( + JX.WorkboardDropEffect.newFromDictionary( + spec.effects[jj])); + } + column.setDropEffects(effects); + + for (jj = 0; jj < spec.cardPHIDs.length; jj++) { + column.newCard(spec.cardPHIDs[jj]); } } @@ -115,11 +128,19 @@ JX.behavior('project-boards', function(config, statics) { for (ii = 0; ii < headers.length; ii++) { var header = headers[ii]; + effects = []; + for (jj = 0; jj < header.effects.length; jj++) { + effects.push( + JX.WorkboardDropEffect.newFromDictionary( + header.effects[jj])); + } + board.getHeaderTemplate(header.key) .setOrder(header.order) .setNodeHTMLTemplate(header.template) .setVector(header.vector) - .setEditProperties(header.editProperties); + .setEditProperties(header.editProperties) + .setDropEffects(effects); } var orders = config.orders; diff --git a/webroot/rsrc/js/core/DraggableList.js b/webroot/rsrc/js/core/DraggableList.js index 64f57503b8..5f19b7061d 100644 --- a/webroot/rsrc/js/core/DraggableList.js +++ b/webroot/rsrc/js/core/DraggableList.js @@ -45,7 +45,8 @@ JX.install('DraggableList', { outerContainer: null, hasInfiniteHeight: false, compareOnMove: false, - compareOnReorder: false + compareOnReorder: false, + targetChangeHandler: null }, members : { @@ -53,6 +54,7 @@ JX.install('DraggableList', { _dragging : null, _locked : 0, _target : null, + _lastTarget: null, _targets : null, _ghostHandler : null, _ghostNode : null, @@ -372,6 +374,19 @@ JX.install('DraggableList', { return this; }, + _didChangeTarget: function(dst_list, dst_node) { + if (dst_node === this._lastTarget) { + return; + } + + this._lastTarget = dst_node; + + var handler = this.getTargetChangeHandler(); + if (handler) { + handler(this, this._dragging, dst_list, dst_node); + } + }, + _setIsDropTarget: function(is_target) { var root = this.getRootNode(); JX.DOM.alterClass(root, 'drag-target-list', is_target); @@ -540,6 +555,8 @@ JX.install('DraggableList', { } } + this._didChangeTarget(target_list, cur_target); + this._updateAutoscroll(this._cursorPosition); var f = JX.$V(this._frame); @@ -673,6 +690,8 @@ JX.install('DraggableList', { group[ii]._clearTarget(); } + this._didChangeTarget(null, null); + JX.DOM.alterClass(dragging, 'drag-dragging', false); JX.Tooltip.unlock(); From a5b3e33e3c2649d7c11a73c9c74e2729a66b8743 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 19 Mar 2019 19:29:33 -0700 Subject: [PATCH 19/52] Don't show workboard action previews if the action won't have any effect Summary: Ref T10335. When you (for example) drag a "Resolved" task into a column with "Trigger: change status to resolved.", don't show a hint that the action will "Change status to resolved." since this isn't helpful and is somewhat confusing. For now, the only visibility operator is "!=" since all current actions are simple field comparisons, but some actions in the future (like "add subscriber" or "remove project") might need other conditions. Test Plan: Dragged cards in ways that previously provided useless hints: move from column A to column B on a "Group by Priority" board; drag a resolved task to a "Trigger: change status to as resolved" column. Saw a more accurate preview in both cases. Drags which actually cause effects still show the effects correctly. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T10335 Differential Revision: https://secure.phabricator.com/D20300 --- resources/celerity/map.php | 40 +++++++++---------- .../PhabricatorProjectBoardViewController.php | 2 + .../icon/PhabricatorProjectDropEffect.php | 16 ++++++++ .../PhabricatorProjectColumnOwnerOrder.php | 1 + .../PhabricatorProjectColumnPriorityOrder.php | 1 + .../PhabricatorProjectColumnStatusOrder.php | 1 + ...catorProjectTriggerManiphestStatusRule.php | 1 + .../js/application/projects/WorkboardBoard.js | 11 +++++ .../projects/WorkboardDropEffect.js | 32 ++++++++++++++- 9 files changed, 83 insertions(+), 22 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 4d095ad2b1..4b732de524 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -408,12 +408,12 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => 'ba6e36b0', + 'rsrc/js/application/projects/WorkboardBoard.js' => '2f893acd', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', 'rsrc/js/application/projects/WorkboardColumn.js' => 'c344eb3c', 'rsrc/js/application/projects/WorkboardController.js' => '42c7a5a7', - 'rsrc/js/application/projects/WorkboardDropEffect.js' => '101121be', + 'rsrc/js/application/projects/WorkboardDropEffect.js' => 'c808589e', 'rsrc/js/application/projects/WorkboardHeader.js' => '111bfd2d', 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'ebe83a6b', 'rsrc/js/application/projects/WorkboardOrderTemplate.js' => '03e8891f', @@ -730,12 +730,12 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => 'ba6e36b0', + 'javelin-workboard-board' => '2f893acd', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', 'javelin-workboard-column' => 'c344eb3c', 'javelin-workboard-controller' => '42c7a5a7', - 'javelin-workboard-drop-effect' => '101121be', + 'javelin-workboard-drop-effect' => 'c808589e', 'javelin-workboard-header' => '111bfd2d', 'javelin-workboard-header-template' => 'ebe83a6b', 'javelin-workboard-order-template' => '03e8891f', @@ -1003,10 +1003,6 @@ return array( 'javelin-workflow', 'phuix-icon-view', ), - '101121be' => array( - 'javelin-install', - 'javelin-dom', - ), '111bfd2d' => array( 'javelin-install', ), @@ -1170,6 +1166,18 @@ return array( 'phuix-autocomplete', 'javelin-mask', ), + '2f893acd' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), '308f9fe4' => array( 'javelin-install', 'javelin-util', @@ -1880,18 +1888,6 @@ return array( 'javelin-uri', 'phabricator-notification', ), - 'ba6e36b0' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), 'bdce4d78' => array( 'javelin-install', 'javelin-util', @@ -1955,6 +1951,10 @@ return array( 'phuix-icon-view', 'phabricator-busy', ), + 'c808589e' => array( + 'javelin-install', + 'javelin-dom', + ), 'c8147a20' => array( 'javelin-behavior', 'javelin-dom', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 41981a5522..6d0c8721fc 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -655,6 +655,8 @@ final class PhabricatorProjectBoardViewController $properties[$task->getPHID()] = array( 'points' => (double)$task->getPoints(), 'status' => $task->getStatus(), + 'priority' => (int)$task->getPriority(), + 'owner' => $task->getOwnerPHID(), ); } diff --git a/src/applications/project/icon/PhabricatorProjectDropEffect.php b/src/applications/project/icon/PhabricatorProjectDropEffect.php index 33145eb039..8e68261fa4 100644 --- a/src/applications/project/icon/PhabricatorProjectDropEffect.php +++ b/src/applications/project/icon/PhabricatorProjectDropEffect.php @@ -6,6 +6,7 @@ final class PhabricatorProjectDropEffect private $icon; private $color; private $content; + private $conditions = array(); public function setIcon($icon) { $this->icon = $icon; @@ -39,7 +40,22 @@ final class PhabricatorProjectDropEffect 'icon' => $this->getIcon(), 'color' => $this->getColor(), 'content' => hsprintf('%s', $this->getContent()), + 'conditions' => $this->getConditions(), ); } + public function addCondition($field, $operator, $value) { + $this->conditions[] = array( + 'field' => $field, + 'operator' => $operator, + 'value' => $value, + ); + + return $this; + } + + public function getConditions() { + return $this->conditions; + } + } diff --git a/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php b/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php index 920e87c9b7..48a6c394db 100644 --- a/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php @@ -171,6 +171,7 @@ final class PhabricatorProjectColumnOwnerOrder $this->newEffect() ->setIcon($owner_icon) ->setColor($owner_color) + ->addCondition('owner', '!=', $owner_phid) ->setContent($effect_content)); } diff --git a/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php b/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php index 8cffab91ae..42ccf96553 100644 --- a/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php @@ -68,6 +68,7 @@ final class PhabricatorProjectColumnPriorityOrder $drop_effect = $this->newEffect() ->setIcon($priority_icon) ->setColor($priority_color) + ->addCondition('priority', '!=', $priority) ->setContent( pht( 'Change priority to %s.', diff --git a/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php b/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php index 419d7062c7..2cb156aa92 100644 --- a/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php @@ -75,6 +75,7 @@ final class PhabricatorProjectColumnStatusOrder $drop_effect = $this->newEffect() ->setIcon($status_icon) ->setColor($status_color) + ->addCondition('status', '!=', $status_key) ->setContent( pht( 'Change status to %s.', diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php index 575f51cd2b..58e8c227df 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php @@ -51,6 +51,7 @@ final class PhabricatorProjectTriggerManiphestStatusRule $this->newEffect() ->setIcon($status_icon) ->setColor($status_color) + ->addCondition('status', '!=', $value) ->setContent($content), ); } diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index 6fab227c84..5a55a2d905 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -228,6 +228,17 @@ JX.install('WorkboardBoard', { effects = effects.concat(header.getDropEffects()); } + var card_phid = JX.Stratcom.getData(src_node).objectPHID; + var card = src_column.getCard(card_phid); + + var visible = []; + for (var ii = 0; ii < effects.length; ii++) { + if (effects[ii].isEffectVisibleForCard(card)) { + visible.push(effects[ii]); + } + } + effects = visible; + if (!effects.length) { JX.DOM.remove(node); return; diff --git a/webroot/rsrc/js/application/projects/WorkboardDropEffect.js b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js index ecd18d0015..fc8b2eaa58 100644 --- a/webroot/rsrc/js/application/projects/WorkboardDropEffect.js +++ b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js @@ -10,7 +10,8 @@ JX.install('WorkboardDropEffect', { properties: { icon: null, color: null, - content: null + content: null, + conditions: [] }, statics: { @@ -18,7 +19,8 @@ JX.install('WorkboardDropEffect', { return new JX.WorkboardDropEffect() .setIcon(map.icon) .setColor(map.color) - .setContent(JX.$H(map.content)); + .setContent(JX.$H(map.content)) + .setConditions(map.conditions || []); } }, @@ -30,6 +32,32 @@ JX.install('WorkboardDropEffect', { .getNode(); return JX.$N('li', {}, [icon, this.getContent()]); + }, + + isEffectVisibleForCard: function(card) { + var conditions = this.getConditions(); + + var properties = card.getProperties(); + for (var ii = 0; ii < conditions.length; ii++) { + var condition = conditions[ii]; + + var field = properties[condition.field]; + var value = condition.value; + + var result = true; + switch (condition.operator) { + case '!=': + result = (field !== value); + break; + } + + if (!result) { + return false; + } + } + + return true; } + } }); From 567dea54492b792d52470fa055ffe56d8002a2f4 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 21 Mar 2019 09:32:12 -0700 Subject: [PATCH 20/52] Mostly make the editor UI for triggers work Summary: Ref T5474. This provides a Herald-like UI for editing workboard trigger rules. This probably has some missing pieces and doesn't actually save anything to the database yet, but the basics at least roughly work. Test Plan: {F6299886} Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20301 --- resources/celerity/map.php | 28 ++++ ...habricatorProjectTriggerEditController.php | 83 ++++++++++- .../PhabricatorProjectTriggerInvalidRule.php | 36 +++++ ...catorProjectTriggerManiphestStatusRule.php | 21 +++ .../trigger/PhabricatorProjectTriggerRule.php | 48 ++++++ .../PhabricatorProjectTriggerUnknownRule.php | 35 +++++ .../application/project/project-triggers.css | 38 +++++ .../js/application/trigger/TriggerRule.js | 140 ++++++++++++++++++ .../application/trigger/TriggerRuleControl.js | 40 +++++ .../application/trigger/TriggerRuleEditor.js | 137 +++++++++++++++++ .../js/application/trigger/TriggerRuleType.js | 36 +++++ .../trigger/trigger-rule-editor.js | 41 +++++ 12 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 webroot/rsrc/css/application/project/project-triggers.css create mode 100644 webroot/rsrc/js/application/trigger/TriggerRule.js create mode 100644 webroot/rsrc/js/application/trigger/TriggerRuleControl.js create mode 100644 webroot/rsrc/js/application/trigger/TriggerRuleEditor.js create mode 100644 webroot/rsrc/js/application/trigger/TriggerRuleType.js create mode 100644 webroot/rsrc/js/application/trigger/trigger-rule-editor.js diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 4b732de524..c12e141770 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -100,6 +100,7 @@ return array( 'rsrc/css/application/policy/policy.css' => 'ceb56a08', 'rsrc/css/application/ponder/ponder-view.css' => '05a09d0a', 'rsrc/css/application/project/project-card-view.css' => '3b1f7b20', + 'rsrc/css/application/project/project-triggers.css' => 'cb866c2d', 'rsrc/css/application/project/project-view.css' => '567858b3', 'rsrc/css/application/releeph/releeph-core.css' => 'f81ff2db', 'rsrc/css/application/releeph/releeph-preview-branch.css' => '22db5c07', @@ -432,6 +433,11 @@ return array( 'rsrc/js/application/transactions/behavior-show-older-transactions.js' => '600f440c', 'rsrc/js/application/transactions/behavior-transaction-comment-form.js' => '2bdadf1a', 'rsrc/js/application/transactions/behavior-transaction-list.js' => '9cec214e', + 'rsrc/js/application/trigger/TriggerRule.js' => 'e4a816a4', + 'rsrc/js/application/trigger/TriggerRuleControl.js' => '5faf27b9', + 'rsrc/js/application/trigger/TriggerRuleEditor.js' => 'b49fd60c', + 'rsrc/js/application/trigger/TriggerRuleType.js' => '4feea7d3', + 'rsrc/js/application/trigger/trigger-rule-editor.js' => '398fdf13', 'rsrc/js/application/typeahead/behavior-typeahead-browse.js' => '70245195', 'rsrc/js/application/typeahead/behavior-typeahead-search.js' => '7b139193', 'rsrc/js/application/uiexample/gesture-example.js' => '242dedd0', @@ -683,6 +689,7 @@ return array( 'javelin-behavior-time-typeahead' => '5803b9e7', 'javelin-behavior-toggle-class' => 'f5c78ae3', 'javelin-behavior-toggle-widget' => '8f959ad0', + 'javelin-behavior-trigger-rule-editor' => '398fdf13', 'javelin-behavior-typeahead-browse' => '70245195', 'javelin-behavior-typeahead-search' => '7b139193', 'javelin-behavior-user-menu' => '60cd9241', @@ -875,6 +882,7 @@ return array( 'policy-transaction-detail-css' => 'c02b8384', 'ponder-view-css' => '05a09d0a', 'project-card-view-css' => '3b1f7b20', + 'project-triggers-css' => 'cb866c2d', 'project-view-css' => '567858b3', 'releeph-core' => 'f81ff2db', 'releeph-preview-branch' => '22db5c07', @@ -886,6 +894,10 @@ return array( 'syntax-default-css' => '055fc231', 'syntax-highlighting-css' => '4234f572', 'tokens-css' => 'ce5a50bd', + 'trigger-rule' => 'e4a816a4', + 'trigger-rule-control' => '5faf27b9', + 'trigger-rule-editor' => 'b49fd60c', + 'trigger-rule-type' => '4feea7d3', 'typeahead-browse-css' => 'b7ed02d2', 'unhandled-exception-css' => '9ecfc00d', ), @@ -1217,6 +1229,12 @@ return array( 'javelin-install', 'javelin-dom', ), + '398fdf13' => array( + 'javelin-behavior', + 'trigger-rule-editor', + 'trigger-rule', + 'trigger-rule-type', + ), '3b4899b0' => array( 'javelin-behavior', 'phabricator-prefab', @@ -1347,6 +1365,9 @@ return array( 'javelin-sound', 'phabricator-notification', ), + '4feea7d3' => array( + 'trigger-rule-control', + ), '506aa3f4' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1432,6 +1453,9 @@ return array( 'javelin-dom', 'phuix-dropdown-menu', ), + '5faf27b9' => array( + 'phuix-form-control-view', + ), '600f440c' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1850,6 +1874,10 @@ return array( 'b347a301' => array( 'javelin-behavior', ), + 'b49fd60c' => array( + 'multirow-row-manager', + 'trigger-rule', + ), 'b517bfa0' => array( 'phui-oi-list-view-css', ), diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php index 86f0844be3..95d1e9f021 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php @@ -65,6 +65,9 @@ final class PhabricatorProjectTriggerEditController $v_name = $request->getStr('name'); $v_edit = $request->getStr('editPolicy'); + $v_rules = $request->getStr('rules'); + $v_rules = phutil_json_decode($v_rules); + $xactions = array(); if (!$trigger->getID()) { $xactions[] = $trigger->getApplicationTransactionTemplate() @@ -81,6 +84,8 @@ final class PhabricatorProjectTriggerEditController ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) ->setNewValue($v_edit); + // TODO: Actually write the new rules to the database. + $editor = $trigger->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) @@ -133,8 +138,14 @@ final class PhabricatorProjectTriggerEditController $header = pht('New Trigger'); } + $form_id = celerity_generate_unique_node_id(); + $table_id = celerity_generate_unique_node_id(); + $create_id = celerity_generate_unique_node_id(); + $input_id = celerity_generate_unique_node_id(); + $form = id(new AphrontFormView()) - ->setViewer($viewer); + ->setViewer($viewer) + ->setID($form_id); if ($column) { $form->addHiddenInput('columnPHID', $column->getPHID()); @@ -161,6 +172,46 @@ final class PhabricatorProjectTriggerEditController ->setPolicies($policies) ->setError($e_edit)); + $form->appendChild( + phutil_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => 'rules', + 'id' => $input_id, + ))); + + $form->appendChild( + id(new PHUIFormInsetView()) + ->setTitle(pht('Rules')) + ->setDescription( + pht( + 'When a card is dropped into a column which uses this trigger:')) + ->setRightButton( + javelin_tag( + 'a', + array( + 'href' => '#', + 'class' => 'button button-green', + 'id' => $create_id, + 'mustcapture' => true, + ), + pht('New Rule'))) + ->setContent( + javelin_tag( + 'table', + array( + 'id' => $table_id, + 'class' => 'trigger-rules-table', + )))); + + $this->setupEditorBehavior( + $trigger, + $form_id, + $table_id, + $create_id, + $input_id); + $form->appendControl( id(new AphrontFormSubmitControl()) ->setValue($submit) @@ -197,4 +248,34 @@ final class PhabricatorProjectTriggerEditController ->appendChild($column_view); } + private function setupEditorBehavior( + PhabricatorProjectTrigger $trigger, + $form_id, + $table_id, + $create_id, + $input_id) { + + $rule_list = $trigger->getTriggerRules(); + $rule_list = mpull($rule_list, 'toDictionary'); + $rule_list = array_values($rule_list); + + $type_list = PhabricatorProjectTriggerRule::getAllTriggerRules(); + $type_list = mpull($type_list, 'newTemplate'); + $type_list = array_values($type_list); + + require_celerity_resource('project-triggers-css'); + + Javelin::initBehavior( + 'trigger-rule-editor', + array( + 'formNodeID' => $form_id, + 'tableNodeID' => $table_id, + 'createNodeID' => $create_id, + 'inputNodeID' => $input_id, + + 'rules' => $rule_list, + 'types' => $type_list, + )); + } + } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php index 4157ec8e9a..0f9fe52abb 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php @@ -11,6 +11,14 @@ final class PhabricatorProjectTriggerInvalidRule $this->getRecord()->getType()); } + public function getSelectControlName() { + return pht('(Invalid Rule)'); + } + + protected function isSelectableRule() { + return false; + } + protected function assertValidRuleValue($value) { return; } @@ -23,4 +31,32 @@ final class PhabricatorProjectTriggerInvalidRule 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; + } + } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php index 58e8c227df..2c40563884 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php @@ -13,6 +13,10 @@ final class PhabricatorProjectTriggerManiphestStatusRule ManiphestTaskStatus::getTaskStatusName($value)); } + public function getSelectControlName() { + return pht('Change status to'); + } + protected function assertValidRuleValue($value) { if (!is_string($value)) { throw new Exception( @@ -56,4 +60,21 @@ final class PhabricatorProjectTriggerManiphestStatusRule ); } + 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), + ); + } + } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php index 6caf9ee0be..9634086235 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -38,9 +38,25 @@ abstract class PhabricatorProjectTriggerRule } abstract public function getDescription(); + abstract public function getSelectControlName(); 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); @@ -95,4 +111,36 @@ abstract class PhabricatorProjectTriggerRule 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 8d796686de..008092061d 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php @@ -11,6 +11,14 @@ final class PhabricatorProjectTriggerUnknownRule $this->getRecord()->getType()); } + public function getSelectControlName() { + return pht('(Unknown Rule)'); + } + + protected function isSelectableRule() { + return false; + } + protected function assertValidRuleValue($value) { return; } @@ -23,4 +31,31 @@ final class PhabricatorProjectTriggerUnknownRule 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; + } + } diff --git a/webroot/rsrc/css/application/project/project-triggers.css b/webroot/rsrc/css/application/project/project-triggers.css new file mode 100644 index 0000000000..9b3ce8e462 --- /dev/null +++ b/webroot/rsrc/css/application/project/project-triggers.css @@ -0,0 +1,38 @@ +/** + * @provides project-triggers-css + */ + +.trigger-rules-table { + margin: 16px 0; + border-collapse: separate; + border-spacing: 0 4px; +} + +.trigger-rules-table tr { + background: {$bluebackground}; +} + +.trigger-rules-table td { + padding: 6px 4px; + vertical-align: middle; +} + +.trigger-rules-table td.type-cell { + padding-left: 6px; +} + +.trigger-rules-table td.remove-column { + padding-right: 6px; +} + +.trigger-rules-table td.invalid-cell { + padding-left: 12px; +} + +.trigger-rules-table td.invalid-cell .phui-icon-view { + margin-right: 4px; +} + +.trigger-rules-table td.value-cell { + width: 100%; +} diff --git a/webroot/rsrc/js/application/trigger/TriggerRule.js b/webroot/rsrc/js/application/trigger/TriggerRule.js new file mode 100644 index 0000000000..69feeade18 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRule.js @@ -0,0 +1,140 @@ +/** + * @provides trigger-rule + * @javelin + */ + +JX.install('TriggerRule', { + + construct: function() { + }, + + properties: { + rowID: null, + type: null, + value: null, + editor: null, + isValidRule: true, + invalidView: null + }, + + statics: { + newFromDictionary: function(map) { + return new JX.TriggerRule() + .setType(map.type) + .setValue(map.value) + .setIsValidRule(map.isValidRule) + .setInvalidView(map.invalidView); + }, + }, + + members: { + _typeCell: null, + _valueCell: null, + _readValueCallback: null, + + newRowContent: function() { + if (!this.getIsValidRule()) { + var invalid_cell = JX.$N( + 'td', + { + colSpan: 2, + className: 'invalid-cell' + }, + JX.$H(this.getInvalidView())); + + return [invalid_cell]; + } + + var type_cell = this._getTypeCell(); + var value_cell = this._getValueCell(); + + + this._rebuildValueControl(); + + return [type_cell, value_cell]; + }, + + getValueForSubmit: function() { + this._readValueFromControl(); + + return { + type: this.getType(), + value: this.getValue() + }; + }, + + _getTypeCell: function() { + if (!this._typeCell) { + var editor = this.getEditor(); + var types = editor.getTypes(); + + var options = []; + for (var ii = 0; ii < types.length; ii++) { + var type = types[ii]; + + if (!type.getIsSelectable()) { + continue; + } + + options.push( + JX.$N('option', {value: type.getType()}, type.getName())); + } + + var control = JX.$N('select', {}, options); + + control.value = this.getType(); + + var on_change = JX.bind(this, this._onTypeChange); + JX.DOM.listen(control, 'onchange', null, on_change); + + var attributes = { + className: 'type-cell' + }; + + this._typeCell = JX.$N('td', attributes, control); + } + + return this._typeCell; + }, + + _onTypeChange: function() { + var control = this._getTypeCell(); + this.setType(control.value); + + this._rebuildValueControl(); + }, + + _getValueCell: function() { + if (!this._valueCell) { + var attributes = { + className: 'value-cell' + }; + + this._valueCell = JX.$N('td', attributes); + } + + return this._valueCell; + }, + + _rebuildValueControl: function() { + var value_cell = this._getValueCell(); + + var editor = this.getEditor(); + var type = editor.getType(this.getType()); + var control = type.getControl(); + + var input = control.newInput(this); + this._readValueCallback = input.get; + + JX.DOM.setContent(value_cell, input.node); + }, + + _readValueFromControl: function() { + if (this._readValueCallback) { + this.setValue(this._readValueCallback()); + } + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleControl.js b/webroot/rsrc/js/application/trigger/TriggerRuleControl.js new file mode 100644 index 0000000000..a05e740ff9 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleControl.js @@ -0,0 +1,40 @@ +/** + * @requires phuix-form-control-view + * @provides trigger-rule-control + * @javelin + */ + +JX.install('TriggerRuleControl', { + + construct: function() { + }, + + properties: { + type: null, + specification: null + }, + + statics: { + newFromDictionary: function(map) { + return new JX.TriggerRuleControl() + .setType(map.type) + .setSpecification(map.specification); + }, + }, + + members: { + newInput: function(rule) { + var phuix = new JX.PHUIXFormControl() + .setControl(this.getType(), this.getSpecification()); + + phuix.setValue(rule.getValue()); + + return { + node: phuix.getRawInputNode(), + get: JX.bind(phuix, phuix.getValue) + }; + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js b/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js new file mode 100644 index 0000000000..3574a8dbca --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js @@ -0,0 +1,137 @@ +/** + * @requires multirow-row-manager + * trigger-rule + * @provides trigger-rule-editor + * @javelin + */ + +JX.install('TriggerRuleEditor', { + + construct: function(form_node) { + this._formNode = form_node; + this._rules = []; + this._types = []; + }, + + members: { + _formNode: null, + _tableNode: null, + _createButtonNode: null, + _inputNode: null, + _rowManager: null, + _rules: null, + _types: null, + + setTableNode: function(table) { + this._tableNode = table; + return this; + }, + + setCreateButtonNode: function(button) { + this._createButtonNode = button; + return this; + }, + + setInputNode: function(input) { + this._inputNode = input; + return this; + }, + + start: function() { + var on_submit = JX.bind(this, this._submitForm); + JX.DOM.listen(this._formNode, 'submit', null, on_submit); + + var manager = new JX.MultirowRowManager(this._tableNode); + this._rowManager = manager; + + var on_remove = JX.bind(this, this._rowRemoved); + manager.listen('row-removed', on_remove); + + var create_button = this._createButtonNode; + var on_create = JX.bind(this, this._createRow); + JX.DOM.listen(create_button, 'click', null, on_create); + }, + + _submitForm: function() { + var values = []; + for (var ii = 0; ii < this._rules.length; ii++) { + var rule = this._rules[ii]; + values.push(rule.getValueForSubmit()); + } + + this._inputNode.value = JX.JSON.stringify(values); + }, + + _createRow: function(e) { + var rule = this.newRule(); + this.addRule(rule); + e.kill(); + }, + + newRule: function() { + // Create new rules with the first valid rule type. + var types = this.getTypes(); + var type; + for (var ii = 0; ii < types.length; ii++) { + type = types[ii]; + if (!type.getIsSelectable()) { + continue; + } + + // If we make it here: this type is valid, so use it. + break; + } + + var default_value = type.getDefaultValue(); + + return new JX.TriggerRule() + .setType(type.getType()) + .setValue(default_value); + }, + + addRule: function(rule) { + rule.setEditor(this); + this._rules.push(rule); + + var manager = this._rowManager; + + var row = manager.addRow([]); + var row_id = manager.getRowID(row); + rule.setRowID(row_id); + + manager.updateRow(row_id, rule.newRowContent()); + }, + + addType: function(type) { + this._types.push(type); + return this; + }, + + getTypes: function() { + return this._types; + }, + + getType: function(type) { + for (var ii = 0; ii < this._types.length; ii++) { + if (this._types[ii].getType() === type) { + return this._types[ii]; + } + } + + return null; + }, + + _rowRemoved: function(row_id) { + for (var ii = 0; ii < this._rules.length; ii++) { + var rule = this._rules[ii]; + + if (rule.getRowID() === row_id) { + this._rules.splice(ii, 1); + break; + } + } + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleType.js b/webroot/rsrc/js/application/trigger/TriggerRuleType.js new file mode 100644 index 0000000000..1075eecedf --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleType.js @@ -0,0 +1,36 @@ +/** + * @requires trigger-rule-control + * @provides trigger-rule-type + * @javelin + */ + +JX.install('TriggerRuleType', { + + construct: function() { + }, + + properties: { + type: null, + name: null, + isSelectable: true, + defaultValue: null, + control: null + }, + + statics: { + newFromDictionary: function(map) { + var control = JX.TriggerRuleControl.newFromDictionary(map.control); + + return new JX.TriggerRuleType() + .setType(map.type) + .setName(map.name) + .setIsSelectable(map.selectable) + .setDefaultValue(map.defaultValue) + .setControl(control); + }, + }, + + members: { + } + +}); diff --git a/webroot/rsrc/js/application/trigger/trigger-rule-editor.js b/webroot/rsrc/js/application/trigger/trigger-rule-editor.js new file mode 100644 index 0000000000..d2741cc337 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/trigger-rule-editor.js @@ -0,0 +1,41 @@ +/** + * @requires javelin-behavior + * trigger-rule-editor + * trigger-rule + * trigger-rule-type + * @provides javelin-behavior-trigger-rule-editor + * @javelin + */ + +JX.behavior('trigger-rule-editor', function(config) { + var form_node = JX.$(config.formNodeID); + var table_node = JX.$(config.tableNodeID); + var create_node = JX.$(config.createNodeID); + var input_node = JX.$(config.inputNodeID); + + var editor = new JX.TriggerRuleEditor(form_node) + .setTableNode(table_node) + .setCreateButtonNode(create_node) + .setInputNode(input_node); + + editor.start(); + + var ii; + + for (ii = 0; ii < config.types.length; ii++) { + var type = JX.TriggerRuleType.newFromDictionary(config.types[ii]); + editor.addType(type); + } + + if (config.rules.length) { + for (ii = 0; ii < config.rules.length; ii++) { + var rule = JX.TriggerRule.newFromDictionary(config.rules[ii]); + editor.addRule(rule); + } + } else { + // If the trigger doesn't have any rules yet, add an empty rule to start + // with, so the user doesn't have to click "New Rule". + editor.addRule(editor.newRule()); + } + +}); From ff128e1b3244dc717a002de76c25f70d72d0a1ee Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 21 Mar 2019 11:48:59 -0700 Subject: [PATCH 21/52] Write workboard trigger rules to the database Summary: Ref T5474. Read and write trigger rules so users can actually edit them. Test Plan: Added, modified, and removed trigger rules. Saved changes, used "Show Details" to review edits. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20302 --- src/__phutil_library_map__.php | 2 + ...habricatorProjectTriggerEditController.php | 20 +- .../storage/PhabricatorProjectTrigger.php | 212 ++++++++++-------- ...icatorProjectTriggerRulesetTransaction.php | 65 ++++++ 4 files changed, 196 insertions(+), 103 deletions(-) create mode 100644 src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index ebe7c2b9c3..a4edf3d246 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4186,6 +4186,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerQuery' => 'applications/project/query/PhabricatorProjectTriggerQuery.php', 'PhabricatorProjectTriggerRule' => 'applications/project/trigger/PhabricatorProjectTriggerRule.php', 'PhabricatorProjectTriggerRuleRecord' => 'applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php', + 'PhabricatorProjectTriggerRulesetTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php', 'PhabricatorProjectTriggerSearchEngine' => 'applications/project/query/PhabricatorProjectTriggerSearchEngine.php', 'PhabricatorProjectTriggerTransaction' => 'applications/project/storage/PhabricatorProjectTriggerTransaction.php', 'PhabricatorProjectTriggerTransactionQuery' => 'applications/project/query/PhabricatorProjectTriggerTransactionQuery.php', @@ -10319,6 +10320,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorProjectTriggerRule' => 'Phobject', 'PhabricatorProjectTriggerRuleRecord' => 'Phobject', + 'PhabricatorProjectTriggerRulesetTransaction' => 'PhabricatorProjectTriggerTransactionType', 'PhabricatorProjectTriggerSearchEngine' => 'PhabricatorApplicationSearchEngine', 'PhabricatorProjectTriggerTransaction' => 'PhabricatorModularTransaction', 'PhabricatorProjectTriggerTransactionQuery' => 'PhabricatorApplicationTransactionQuery', diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php index 95d1e9f021..7189df70ec 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php @@ -55,6 +55,7 @@ final class PhabricatorProjectTriggerEditController $v_name = $trigger->getName(); $v_edit = $trigger->getEditPolicy(); + $v_rules = $trigger->getTriggerRules(); $e_name = null; $e_edit = null; @@ -65,8 +66,15 @@ final class PhabricatorProjectTriggerEditController $v_name = $request->getStr('name'); $v_edit = $request->getStr('editPolicy'); - $v_rules = $request->getStr('rules'); - $v_rules = phutil_json_decode($v_rules); + // Read the JSON rules from the request and convert them back into + // "TriggerRule" objects so we can render the correct form state + // if the user is modifying the rules + $raw_rules = $request->getStr('rules'); + $raw_rules = phutil_json_decode($raw_rules); + + $copy = clone $trigger; + $copy->setRuleset($raw_rules); + $v_rules = $copy->getTriggerRules(); $xactions = array(); if (!$trigger->getID()) { @@ -84,7 +92,10 @@ final class PhabricatorProjectTriggerEditController ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) ->setNewValue($v_edit); - // TODO: Actually write the new rules to the database. + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectTriggerRulesetTransaction::TRANSACTIONTYPE) + ->setNewValue($raw_rules); $editor = $trigger->getApplicationTransactionEditor() ->setActor($viewer) @@ -207,6 +218,7 @@ final class PhabricatorProjectTriggerEditController $this->setupEditorBehavior( $trigger, + $v_rules, $form_id, $table_id, $create_id, @@ -250,12 +262,12 @@ final class PhabricatorProjectTriggerEditController private function setupEditorBehavior( PhabricatorProjectTrigger $trigger, + array $rule_list, $form_id, $table_id, $create_id, $input_id) { - $rule_list = $trigger->getTriggerRules(); $rule_list = mpull($rule_list, 'toDictionary'); $rule_list = array_values($rule_list); diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php index b02e1ce107..a195e9fc44 100644 --- a/src/applications/project/storage/PhabricatorProjectTrigger.php +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -62,107 +62,19 @@ final class PhabricatorProjectTrigger 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) { - - // TODO: Temporary hard-coded rule specification. - $rule_specifications = array( - array( - 'type' => 'status', - 'value' => ManiphestTaskStatus::getDefaultClosedStatus(), - ), - // This is an intentionally unknown rule. - array( - 'type' => 'quack', - 'value' => 'aaa', - ), - // This is an intentionally invalid rule. - array( - 'type' => 'status', - 'value' => 'quack', - ), - ); - - // NOTE: We're trying to preserve the database state in the rule - // structure, even if it includes rule types we don't have 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. - - // 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($rule_specifications)) { - throw new PhabricatorProjectTriggerCorruptionException( - pht( - 'Trigger ("%s") has a corrupt ruleset: expected a list of '. - 'rule specifications, found "%s".', - $this->getPHID(), - phutil_describe_type($rule_specifications))); - } - - $trigger_rules = array(); - foreach ($rule_specifications as $key => $rule) { - if (!is_array($rule)) { - throw new PhabricatorProjectTriggerCorruptionException( - pht( - 'Trigger ("%s") has a corrupt ruleset: rule (with key "%s") '. - 'should be a rule specification, but is actually "%s".', - $this->getPHID(), - $key, - phutil_describe_type($rule))); - } - - try { - PhutilTypeSpec::checkMap( - $rule, - array( - 'type' => 'string', - 'value' => 'wild', - )); - } catch (PhutilTypeCheckException $ex) { - throw new PhabricatorProjectTriggerCorruptionException( - pht( - 'Trigger ("%s") has a corrupt ruleset: rule (with key "%s") '. - 'is not a valid rule specification: %s', - $this->getPHID(), - $key, - $ex->getMessage())); - } - - $record = id(new PhabricatorProjectTriggerRuleRecord()) - ->setType(idx($rule, 'type')) - ->setValue(idx($rule, 'value')); - - if (!isset($rule_map[$record->getType()])) { - $rule = new PhabricatorProjectTriggerUnknownRule(); - } else { - $rule = clone $rule_map[$record->getType()]; - } - - try { - $rule->setRecord($record); - } catch (Exception $ex) { - $rule = id(new PhabricatorProjectTriggerInvalidRule()) - ->setRecord($record); - } - - $trigger_rules[] = $rule; - } + $trigger_rules = self::newTriggerRulesFromRuleSpecifications( + $this->getRuleset(), + $allow_invalid = true); $this->triggerRules = $trigger_rules; } @@ -170,6 +82,108 @@ final class PhabricatorProjectTrigger 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); + } + + $trigger_rules[] = $rule; + } + + return $trigger_rules; + } + + public function getDropEffects() { $effects = array(); diff --git a/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php new file mode 100644 index 0000000000..59c846becf --- /dev/null +++ b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php @@ -0,0 +1,65 @@ +getRuleset(); + } + + public function applyInternalEffects($object, $value) { + $object->setRuleset($value); + } + + public function getTitle() { + return pht( + '%s updated the ruleset for this trigger.', + $this->renderAuthor()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + foreach ($xactions as $xaction) { + $ruleset = $xaction->getNewValue(); + + try { + PhabricatorProjectTrigger::newTriggerRulesFromRuleSpecifications( + $ruleset, + $allow_invalid = false); + } catch (PhabricatorProjectTriggerCorruptionException $ex) { + $errors[] = $this->newInvalidError( + pht( + 'Ruleset specification is not valid. %s', + $ex->getMessage()), + $xaction); + continue; + } + } + + return $errors; + } + + public function hasChangeDetailView() { + return true; + } + + public function newChangeDetailView() { + $viewer = $this->getViewer(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $json = new PhutilJSON(); + $old_json = $json->encodeAsList($old); + $new_json = $json->encodeAsList($new); + + return id(new PhabricatorApplicationTransactionTextDiffDetailView()) + ->setViewer($viewer) + ->setOldText($old_json) + ->setNewText($new_json); + } + +} From 614f39b806790d6298e0a0b3d11aac95f7a8d8bc Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 21 Mar 2019 12:27:14 -0700 Subject: [PATCH 22/52] Show a trigger rule summary on the rule view page Summary: Ref T5474. When you view the main page for a rule, show what the rule does before you actually edit it. Test Plan: Viewed a real trigger, then faked invalid/unknown rules: {F6300211} {F6300212} {F6300213} Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20303 --- ...habricatorProjectTriggerViewController.php | 44 +++++++++++++++++++ .../storage/PhabricatorProjectTrigger.php | 3 +- .../PhabricatorProjectTriggerInvalidRule.php | 37 ++++++++++++++++ ...catorProjectTriggerManiphestStatusRule.php | 21 +++++++++ .../trigger/PhabricatorProjectTriggerRule.php | 3 ++ .../PhabricatorProjectTriggerUnknownRule.php | 16 +++++++ 6 files changed, 123 insertions(+), 1 deletion(-) 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 @@ -21,6 +21,7 @@ final class PhabricatorProjectTriggerViewController return new Aphront404Response(); } + $rules_view = $this->newRulesView($trigger); $columns_view = $this->newColumnsView($trigger); $title = $trigger->getObjectName(); @@ -40,6 +41,7 @@ final class PhabricatorProjectTriggerViewController ->setCurtain($curtain) ->setMainColumn( array( + $rules_view, $columns_view, $timeline, )); @@ -139,6 +141,48 @@ final class PhabricatorProjectTriggerViewController ->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(); 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 @@ -174,7 +174,8 @@ final class PhabricatorProjectTrigger } $rule = id(new PhabricatorProjectTriggerInvalidRule()) - ->setRecord($record); + ->setRecord($record) + ->setException($ex); } $trigger_rules[] = $rule; 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 @@ -5,6 +5,17 @@ final class PhabricatorProjectTriggerInvalidRule const TRIGGERTYPE = 'invalid'; + private $exception; + + public function setException(Exception $exception) { + $this->exception = $exception; + return $this; + } + + public function getException() { + return $this->exception; + } + public function getDescription() { return pht( 'Invalid rule (of type "%s").', @@ -59,4 +70,30 @@ final class PhabricatorProjectTriggerInvalidRule 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 @@ -77,4 +77,25 @@ final class PhabricatorProjectTriggerManiphestStatusRule ); } + 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 @@ -39,6 +39,9 @@ abstract class PhabricatorProjectTriggerRule 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); 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 @@ -58,4 +58,20 @@ final class PhabricatorProjectTriggerUnknownRule 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'); + } + } From 1277db9452585988ad19abc7b883c7387285e4d1 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 21 Mar 2019 13:19:12 -0700 Subject: [PATCH 23/52] When users hover over a column trigger menu, show a "preview" with the rules instead of a tooltip Summary: Ref T5474. The first rough cut of triggers showed some of the trigger rules in a tooltip when you hover over the "add/remove" trigger menu. This isn't great since we don't have much room and it's a bit finnicky / hard to read. Since we have a better way to show effects now in the drop preview, just use that instead. When you hover over the trigger menu, preview the trigger in the "drop effect" element, with a "Trigger: such-and-such" header. Test Plan: - This is pretty tough to screenshot. - Hovered over menu, got a sensible preview of the trigger effects. - Dragged a card over the menu, no preview. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20304 --- resources/celerity/map.php | 74 +++++++-------- .../PhabricatorProjectBoardViewController.php | 33 +++---- .../engine/PhabricatorBoardResponseEngine.php | 14 ++- .../icon/PhabricatorProjectDropEffect.php | 22 +++++ .../storage/PhabricatorProjectTrigger.php | 39 ++------ .../PhabricatorProjectTriggerInvalidRule.php | 6 -- ...catorProjectTriggerManiphestStatusRule.php | 8 -- .../trigger/PhabricatorProjectTriggerRule.php | 4 +- .../PhabricatorProjectTriggerUnknownRule.php | 6 -- .../css/phui/workboards/phui-workpanel.css | 11 +++ .../js/application/projects/WorkboardBoard.js | 95 +++++++++++++++++-- .../application/projects/WorkboardColumn.js | 4 + .../projects/WorkboardDropEffect.js | 12 ++- .../projects/behavior-project-boards.js | 6 ++ 14 files changed, 215 insertions(+), 119 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index c12e141770..ffb8209f41 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -179,7 +179,7 @@ return array( 'rsrc/css/phui/workboards/phui-workboard-color.css' => 'e86de308', 'rsrc/css/phui/workboards/phui-workboard.css' => '74fc9d98', 'rsrc/css/phui/workboards/phui-workcard.css' => '9e9eb0df', - 'rsrc/css/phui/workboards/phui-workpanel.css' => 'e5461a51', + 'rsrc/css/phui/workboards/phui-workpanel.css' => '4e4ec9f0', 'rsrc/css/sprite-login.css' => '18b368a6', 'rsrc/css/sprite-tokens.css' => 'f1896dc5', 'rsrc/css/syntax/syntax-default.css' => '055fc231', @@ -409,16 +409,16 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '2f893acd', + 'rsrc/js/application/projects/WorkboardBoard.js' => '31766c31', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', - 'rsrc/js/application/projects/WorkboardColumn.js' => 'c344eb3c', + 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', 'rsrc/js/application/projects/WorkboardController.js' => '42c7a5a7', - 'rsrc/js/application/projects/WorkboardDropEffect.js' => 'c808589e', + 'rsrc/js/application/projects/WorkboardDropEffect.js' => '8e0aa661', 'rsrc/js/application/projects/WorkboardHeader.js' => '111bfd2d', 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'ebe83a6b', 'rsrc/js/application/projects/WorkboardOrderTemplate.js' => '03e8891f', - 'rsrc/js/application/projects/behavior-project-boards.js' => 'cd7c9d4f', + 'rsrc/js/application/projects/behavior-project-boards.js' => '8512e4ea', 'rsrc/js/application/projects/behavior-project-create.js' => '34c53422', 'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68', @@ -664,7 +664,7 @@ return array( 'javelin-behavior-phuix-example' => 'c2c500a7', 'javelin-behavior-policy-control' => '0eaa33a9', 'javelin-behavior-policy-rule-editor' => '9347f172', - 'javelin-behavior-project-boards' => 'cd7c9d4f', + 'javelin-behavior-project-boards' => '8512e4ea', 'javelin-behavior-project-create' => '34c53422', 'javelin-behavior-quicksand-blacklist' => '5a6f6a06', 'javelin-behavior-read-only-warning' => 'b9109f8f', @@ -737,12 +737,12 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '2f893acd', + 'javelin-workboard-board' => '31766c31', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', - 'javelin-workboard-column' => 'c344eb3c', + 'javelin-workboard-column' => 'c3d24e63', 'javelin-workboard-controller' => '42c7a5a7', - 'javelin-workboard-drop-effect' => 'c808589e', + 'javelin-workboard-drop-effect' => '8e0aa661', 'javelin-workboard-header' => '111bfd2d', 'javelin-workboard-header-template' => 'ebe83a6b', 'javelin-workboard-order-template' => '03e8891f', @@ -869,7 +869,7 @@ return array( 'phui-workboard-color-css' => 'e86de308', 'phui-workboard-view-css' => '74fc9d98', 'phui-workcard-view-css' => '9e9eb0df', - 'phui-workpanel-view-css' => 'e5461a51', + 'phui-workpanel-view-css' => '4e4ec9f0', 'phuix-action-list-view' => 'c68f183f', 'phuix-action-view' => 'aaa08f3b', 'phuix-autocomplete' => '8f139ef0', @@ -1178,7 +1178,11 @@ return array( 'phuix-autocomplete', 'javelin-mask', ), - '2f893acd' => array( + '308f9fe4' => array( + 'javelin-install', + 'javelin-util', + ), + '31766c31' => array( 'javelin-install', 'javelin-dom', 'javelin-util', @@ -1190,10 +1194,6 @@ return array( 'javelin-workboard-card-template', 'javelin-workboard-order-template', ), - '308f9fe4' => array( - 'javelin-install', - 'javelin-util', - ), '32755edb' => array( 'javelin-install', 'javelin-util', @@ -1351,6 +1351,9 @@ return array( 'phuix-icon-view', 'javelin-behavior-phabricator-gesture', ), + '4e4ec9f0' => array( + 'phui-workcard-view-css', + ), '4e61fa88' => array( 'javelin-behavior', 'javelin-aphlict', @@ -1591,6 +1594,16 @@ return array( 'javelin-dom', 'javelin-vector', ), + '8512e4ea' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'javelin-vector', + 'javelin-stratcom', + 'javelin-workflow', + 'javelin-workboard-controller', + 'javelin-workboard-drop-effect', + ), '87428eb2' => array( 'javelin-behavior', 'javelin-diffusion-locate-file-source', @@ -1636,6 +1649,10 @@ return array( 'phabricator-shaped-request', 'conpherence-thread-manager', ), + '8e0aa661' => array( + 'javelin-install', + 'javelin-dom', + ), '8e2d9a28' => array( 'phui-theme-css', ), @@ -1940,17 +1957,17 @@ return array( 'javelin-dom', 'phuix-button-view', ), - 'c344eb3c' => array( - 'javelin-install', - 'javelin-workboard-card', - 'javelin-workboard-header', - ), 'c3703a16' => array( 'javelin-behavior', 'javelin-aphlict', 'phabricator-phtize', 'javelin-dom', ), + 'c3d24e63' => array( + 'javelin-install', + 'javelin-workboard-card', + 'javelin-workboard-header', + ), 'c687e867' => array( 'javelin-behavior', 'javelin-dom', @@ -1979,10 +1996,6 @@ return array( 'phuix-icon-view', 'phabricator-busy', ), - 'c808589e' => array( - 'javelin-install', - 'javelin-dom', - ), 'c8147a20' => array( 'javelin-behavior', 'javelin-dom', @@ -2002,16 +2015,6 @@ return array( 'javelin-vector', 'javelin-magical-init', ), - 'cd7c9d4f' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-util', - 'javelin-vector', - 'javelin-stratcom', - 'javelin-workflow', - 'javelin-workboard-controller', - 'javelin-workboard-drop-effect', - ), 'cf32921f' => array( 'javelin-behavior', 'javelin-dom', @@ -2072,9 +2075,6 @@ return array( 'javelin-dom', 'javelin-history', ), - 'e5461a51' => array( - 'phui-workcard-view-css', - ), 'e562708c' => array( 'javelin-install', ), diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 6d0c8721fc..4e4ff81b4e 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -623,10 +623,20 @@ final class PhabricatorProjectBoardViewController $drop_effects = $column->getDropEffects(); $drop_effects = mpull($drop_effects, 'toDictionary'); + $preview_effect = null; + if ($column->canHaveTrigger()) { + $trigger = $column->getTrigger(); + if ($trigger) { + $preview_effect = $trigger->getPreviewEffect() + ->toDictionary(); + } + } + $column_templates[] = array( 'columnPHID' => $column_phid, 'effects' => $drop_effects, 'cardPHIDs' => $card_phids, + 'triggerPreviewEffect' => $preview_effect, ); } @@ -652,12 +662,8 @@ final class PhabricatorProjectBoardViewController $properties = array(); foreach ($all_tasks as $task) { - $properties[$task->getPHID()] = array( - 'points' => (double)$task->getPoints(), - 'status' => $task->getStatus(), - 'priority' => (int)$task->getPriority(), - 'owner' => $task->getOwnerPHID(), - ); + $properties[$task->getPHID()] = + PhabricatorBoardResponseEngine::newTaskProperties($task); } $behavior_config = array( @@ -1263,26 +1269,15 @@ final class PhabricatorProjectBoardViewController $trigger_icon = 'fa-cogs grey'; } - if ($trigger) { - $trigger_tip = array( - pht('%s: %s', $trigger->getObjectName(), $trigger->getDisplayName()), - $trigger->getRulesDescription(), - ); - $trigger_tip = implode("\n", $trigger_tip); - } else { - $trigger_tip = pht('No column trigger.'); - } - $trigger_button = id(new PHUIIconView()) ->setIcon($trigger_icon) ->setHref('#') ->addSigil('boards-dropdown-menu') - ->addSigil('has-tooltip') + ->addSigil('trigger-preview') ->setMetadata( array( 'items' => hsprintf('%s', $trigger_menu), - 'tip' => $trigger_tip, - 'size' => 300, + 'columnPHID' => $column->getPHID(), )); return $trigger_button; diff --git a/src/applications/project/engine/PhabricatorBoardResponseEngine.php b/src/applications/project/engine/PhabricatorBoardResponseEngine.php index 36c5e81150..fb5299a857 100644 --- a/src/applications/project/engine/PhabricatorBoardResponseEngine.php +++ b/src/applications/project/engine/PhabricatorBoardResponseEngine.php @@ -131,10 +131,7 @@ final class PhabricatorBoardResponseEngine extends Phobject { $card['headers'][$order_key] = $header; } - $card['properties'] = array( - 'points' => (double)$object->getPoints(), - 'status' => $object->getStatus(), - ); + $card['properties'] = self::newTaskProperties($object); } if ($card_phid === $object_phid) { @@ -159,6 +156,15 @@ final class PhabricatorBoardResponseEngine extends Phobject { ->setContent($payload); } + public static function newTaskProperties($task) { + return array( + 'points' => (double)$task->getPoints(), + 'status' => $task->getStatus(), + 'priority' => (int)$task->getPriority(), + 'owner' => $task->getOwnerPHID(), + ); + } + private function buildTemplate($object) { $viewer = $this->getViewer(); $object_phid = $this->getObjectPHID(); diff --git a/src/applications/project/icon/PhabricatorProjectDropEffect.php b/src/applications/project/icon/PhabricatorProjectDropEffect.php index 8e68261fa4..3d61f9bcef 100644 --- a/src/applications/project/icon/PhabricatorProjectDropEffect.php +++ b/src/applications/project/icon/PhabricatorProjectDropEffect.php @@ -7,6 +7,8 @@ final class PhabricatorProjectDropEffect private $color; private $content; private $conditions = array(); + private $isTriggerEffect; + private $isHeader; public function setIcon($icon) { $this->icon = $icon; @@ -40,6 +42,8 @@ final class PhabricatorProjectDropEffect 'icon' => $this->getIcon(), 'color' => $this->getColor(), 'content' => hsprintf('%s', $this->getContent()), + 'isTriggerEffect' => $this->getIsTriggerEffect(), + 'isHeader' => $this->getIsHeader(), 'conditions' => $this->getConditions(), ); } @@ -58,4 +62,22 @@ final class PhabricatorProjectDropEffect return $this->conditions; } + public function setIsTriggerEffect($is_trigger_effect) { + $this->isTriggerEffect = $is_trigger_effect; + return $this; + } + + public function getIsTriggerEffect() { + return $this->isTriggerEffect; + } + + public function setIsHeader($is_header) { + $this->isHeader = $is_header; + return $this; + } + + public function getIsHeader() { + return $this->isHeader; + } + } diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php index 5029c2caea..a499f0bcf8 100644 --- a/src/applications/project/storage/PhabricatorProjectTrigger.php +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -198,36 +198,6 @@ final class PhabricatorProjectTrigger 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, @@ -265,6 +235,15 @@ final class PhabricatorProjectTrigger return $trigger_xactions; } + public function getPreviewEffect() { + $header = pht('Trigger: %s', $this->getDisplayName()); + + return id(new PhabricatorProjectDropEffect()) + ->setIcon('fa-cogs') + ->setColor('blue') + ->setIsHeader(true) + ->setContent($header); + } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php index 184d818aa5..ba53b77e75 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php @@ -16,12 +16,6 @@ final class PhabricatorProjectTriggerInvalidRule return $this->exception; } - public function getDescription() { - return pht( - 'Invalid rule (of type "%s").', - $this->getRecord()->getType()); - } - public function getSelectControlName() { return pht('(Invalid Rule)'); } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php index 5b1ad2db36..b11d7567de 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php @@ -5,14 +5,6 @@ final class PhabricatorProjectTriggerManiphestStatusRule const TRIGGERTYPE = 'task.status'; - public function getDescription() { - $value = $this->getValue(); - - return pht( - 'Changes status to "%s".', - ManiphestTaskStatus::getTaskStatusName($value)); - } - public function getSelectControlName() { return pht('Change status to'); } diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php index 49fdbf8a93..c75c15a1ab 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -37,7 +37,6 @@ abstract class PhabricatorProjectTriggerRule return $this->getRecord()->getValue(); } - abstract public function getDescription(); abstract public function getSelectControlName(); abstract public function getRuleViewLabel(); abstract public function getRuleViewDescription($value); @@ -111,7 +110,8 @@ abstract class PhabricatorProjectTriggerRule } final protected function newEffect() { - return new PhabricatorProjectDropEffect(); + return id(new PhabricatorProjectDropEffect()) + ->setIsTriggerEffect(true); } final public function toDictionary() { diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php index f71ee44ad7..925a369bae 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php @@ -5,12 +5,6 @@ final class PhabricatorProjectTriggerUnknownRule const TRIGGERTYPE = 'unknown'; - public function getDescription() { - return pht( - 'Unknown rule (of type "%s").', - $this->getRecord()->getType()); - } - public function getSelectControlName() { return pht('(Unknown Rule)'); } diff --git a/webroot/rsrc/css/phui/workboards/phui-workpanel.css b/webroot/rsrc/css/phui/workboards/phui-workpanel.css index ce0e7885a8..97600ff5e6 100644 --- a/webroot/rsrc/css/phui/workboards/phui-workpanel.css +++ b/webroot/rsrc/css/phui/workboards/phui-workpanel.css @@ -201,6 +201,7 @@ text-overflow: ellipsis; margin: 4px 8px; color: {$greytext}; + border-radius: 3px; } .workboard-drop-preview li .phui-icon-view { @@ -214,3 +215,13 @@ background: {$bluebackground}; margin-right: 6px; } + +.workboard-drop-preview .workboard-drop-preview-header { + background: {$sky}; + color: #fff; +} + +.workboard-drop-preview .workboard-drop-preview-header .phui-icon-view { + background: {$blue}; + color: #fff; +} diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index 5a55a2d905..f96e82fb8b 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -41,6 +41,8 @@ JX.install('WorkboardBoard', { _cards: null, _dropPreviewNode: null, _dropPreviewListNode: null, + _previewPHID: null, + _hidePreivew: false, getRoot: function() { return this._root; @@ -141,6 +143,82 @@ JX.install('WorkboardBoard', { this._columns[phid] = new JX.WorkboardColumn(this, phid, node); } + + var on_over = JX.bind(this, this._showTriggerPreview); + var on_out = JX.bind(this, this._hideTriggerPreview); + JX.Stratcom.listen('mouseover', 'trigger-preview', on_over); + JX.Stratcom.listen('mouseout', 'trigger-preview', on_out); + }, + + _showTriggerPreview: function(e) { + if (this._disablePreview) { + return; + } + + var target = e.getTarget(); + var node = e.getNode('trigger-preview'); + + if (target !== node) { + return; + } + + var phid = JX.Stratcom.getData(node).columnPHID; + var column = this._columns[phid]; + + // Bail out if we don't know anything about this column. + if (!column) { + return; + } + + if (phid === this._previewPHID) { + return; + } + + this._previewPHID = phid; + + var effects = column.getDropEffects(); + + var triggers = []; + for (var ii = 0; ii < effects.length; ii++) { + if (effects[ii].getIsTriggerEffect()) { + triggers.push(effects[ii]); + } + } + + if (triggers.length) { + var header = column.getTriggerPreviewEffect(); + triggers = [header].concat(triggers); + } + + this._showEffects(triggers); + }, + + _hideTriggerPreview: function(e) { + if (this._disablePreview) { + return; + } + + var target = e.getTarget(); + + if (target !== e.getNode('trigger-preview')) { + return; + } + + this._removeTriggerPreview(); + }, + + _removeTriggerPreview: function() { + this._showEffects([]); + this._previewPHID = null; + }, + + _beginDrag: function() { + this._disablePreview = true; + this._showEffects([]); + }, + + _endDrag: function() { + this._disablePreview = false; }, _setupDragHandlers: function() { @@ -186,6 +264,9 @@ JX.install('WorkboardBoard', { list.listen('didDrop', JX.bind(this, this._onmovecard, list)); + list.listen('didBeginDrag', JX.bind(this, this._beginDrag)); + list.listen('didEndDrag', JX.bind(this, this._endDrag)); + lists.push(list); } @@ -195,18 +276,16 @@ JX.install('WorkboardBoard', { }, _didChangeDropTarget: function(src_list, src_node, dst_list, dst_node) { - var node = this._getDropPreviewNode(); - if (!dst_list) { // The card is being dragged into a dead area, like the left menu. - JX.DOM.remove(node); + this._showEffects([]); return; } if (dst_node === false) { // The card is being dragged over itself, so dropping it won't // affect anything. - JX.DOM.remove(node); + this._showEffects([]); return; } @@ -217,7 +296,6 @@ JX.install('WorkboardBoard', { var dst_column = this.getColumn(dst_phid); var effects = []; - if (src_column !== dst_column) { effects = effects.concat(dst_column.getDropEffects()); } @@ -239,6 +317,12 @@ JX.install('WorkboardBoard', { } effects = visible; + this._showEffects(effects); + }, + + _showEffects: function(effects) { + var node = this._getDropPreviewNode(); + if (!effects.length) { JX.DOM.remove(node); return; @@ -251,7 +335,6 @@ JX.install('WorkboardBoard', { } JX.DOM.setContent(this._getDropPreviewListNode(), items); - document.body.appendChild(node); }, diff --git a/webroot/rsrc/js/application/projects/WorkboardColumn.js b/webroot/rsrc/js/application/projects/WorkboardColumn.js index 593afea776..a9bf0f8cc5 100644 --- a/webroot/rsrc/js/application/projects/WorkboardColumn.js +++ b/webroot/rsrc/js/application/projects/WorkboardColumn.js @@ -28,6 +28,10 @@ JX.install('WorkboardColumn', { this._dropEffects = []; }, + properties: { + triggerPreviewEffect: null + }, + members: { _phid: null, _root: null, diff --git a/webroot/rsrc/js/application/projects/WorkboardDropEffect.js b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js index fc8b2eaa58..0c729fc517 100644 --- a/webroot/rsrc/js/application/projects/WorkboardDropEffect.js +++ b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js @@ -11,6 +11,8 @@ JX.install('WorkboardDropEffect', { icon: null, color: null, content: null, + isTriggerEffect: false, + isHeader: false, conditions: [] }, @@ -20,6 +22,8 @@ JX.install('WorkboardDropEffect', { .setIcon(map.icon) .setColor(map.color) .setContent(JX.$H(map.content)) + .setIsTriggerEffect(map.isTriggerEffect) + .setIsHeader(map.isHeader) .setConditions(map.conditions || []); } }, @@ -31,7 +35,13 @@ JX.install('WorkboardDropEffect', { .setColor(this.getColor()) .getNode(); - return JX.$N('li', {}, [icon, this.getContent()]); + var attributes = {}; + + if (this.getIsHeader()) { + attributes.className = 'workboard-drop-preview-header'; + } + + return JX.$N('li', attributes, [icon, this.getContent()]); }, isEffectVisibleForCard: function(card) { diff --git a/webroot/rsrc/js/application/projects/behavior-project-boards.js b/webroot/rsrc/js/application/projects/behavior-project-boards.js index f25599391f..daec59155f 100644 --- a/webroot/rsrc/js/application/projects/behavior-project-boards.js +++ b/webroot/rsrc/js/application/projects/behavior-project-boards.js @@ -108,6 +108,12 @@ JX.behavior('project-boards', function(config, statics) { for (jj = 0; jj < spec.cardPHIDs.length; jj++) { column.newCard(spec.cardPHIDs[jj]); } + + if (spec.triggerPreviewEffect) { + column.setTriggerPreviewEffect( + JX.WorkboardDropEffect.newFromDictionary( + spec.triggerPreviewEffect)); + } } var order_maps = config.orderMaps; From 66c1d623c3406120b65dab78f1bd297195c01a85 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 21 Mar 2019 13:37:43 -0700 Subject: [PATCH 24/52] If the user cancels a workboard drop flow, put things back where they were Summary: Ref T13074. If you hit a prompt on a drop operation (today: MFA; in the future, maybe "add a comment" or "assign this task"), we currently leave the board in a bad semi-frozen state if you cancel the workflow by pressing "Cancel" on the dialog. Instead, put things back the way they were. Test Plan: Dragged an MFA-required card, cancelled the MFA prompt, got a functional board instead of a semi-frozen board I needed to reload. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13074 Differential Revision: https://secure.phabricator.com/D20305 --- resources/celerity/map.php | 28 +++++++++---------- .../js/application/projects/WorkboardBoard.js | 25 +++++++++++++++++ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index ffb8209f41..62cdf2f1e3 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -409,7 +409,7 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '31766c31', + 'rsrc/js/application/projects/WorkboardBoard.js' => '65afb173', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', @@ -737,7 +737,7 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '31766c31', + 'javelin-workboard-board' => '65afb173', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', 'javelin-workboard-column' => 'c3d24e63', @@ -1182,18 +1182,6 @@ return array( 'javelin-install', 'javelin-util', ), - '31766c31' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), '32755edb' => array( 'javelin-install', 'javelin-util', @@ -1468,6 +1456,18 @@ return array( '60cd9241' => array( 'javelin-behavior', ), + '65afb173' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), '65bb0011' => array( 'javelin-behavior', 'javelin-dom', diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index f96e82fb8b..a7786a86f4 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -479,6 +479,17 @@ JX.install('WorkboardBoard', { data.visiblePHIDs = visible_phids.join(','); + // If the user cancels the workflow (for example, by hitting an MFA + // prompt that they click "Cancel" on), put the card back where it was + // and reset the UI state. + var on_revert = JX.bind( + this, + this._revertCard, + list, + item, + src_phid, + dst_phid); + var onupdate = JX.bind( this, this._oncardupdate, @@ -489,9 +500,23 @@ JX.install('WorkboardBoard', { new JX.Workflow(this.getController().getMoveURI(), data) .setHandler(onupdate) + .setCloseHandler(on_revert) .start(); }, + _revertCard: function(list, item, src_phid, dst_phid) { + JX.DOM.alterClass(item, 'drag-sending', false); + + var src_column = this.getColumn(src_phid); + var dst_column = this.getColumn(dst_phid); + + src_column.markForRedraw(); + dst_column.markForRedraw(); + this._redrawColumns(); + + list.unlock(); + }, + _oncardupdate: function(list, src_phid, dst_phid, after_phid, response) { var src_column = this.getColumn(src_phid); var dst_column = this.getColumn(dst_phid); From bfa5ffe8a1eaed1f0e503bb2db65e2882b1ed9c4 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 21 Mar 2019 15:22:09 -0700 Subject: [PATCH 25/52] Add a "Play Sound" workboard trigger rule Summary: Ref T5474. Allow columns to play a sound when tasks are dropped. This is a little tricky because Safari has changed somewhat recently to require some gymnastics to play sounds when the user didn't explicitly click something. Preloading the sound on the first mouse interaction, then playing and immediately pausing it seems to work, though. Test Plan: Added a trigger with 5 sounds. In Safari, Chrome, and Firefox, dropped a card into the column. In all browsers, heard a nice sequence of 5 sounds played one after the other. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20306 --- resources/celerity/map.php | 64 ++++----- src/__phutil_library_map__.php | 2 + .../PhabricatorProjectBoardViewController.php | 6 + .../PhabricatorProjectController.php | 6 +- .../PhabricatorProjectMoveController.php | 11 +- .../engine/PhabricatorBoardResponseEngine.php | 11 ++ .../storage/PhabricatorProjectTrigger.php | 12 ++ ...PhabricatorProjectTriggerPlaySoundRule.php | 122 ++++++++++++++++++ .../trigger/PhabricatorProjectTriggerRule.php | 4 + webroot/rsrc/externals/javelin/lib/Sound.js | 50 ++++++- .../js/application/projects/WorkboardBoard.js | 5 + .../projects/behavior-project-boards.js | 12 ++ 12 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 62cdf2f1e3..5dd506784a 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -10,7 +10,7 @@ return array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', 'core.pkg.css' => 'b797945d', - 'core.pkg.js' => 'eaca003c', + 'core.pkg.js' => 'eb53fc5b', 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', 'diffusion.pkg.css' => '42c75c37', @@ -249,7 +249,7 @@ return array( 'rsrc/externals/javelin/lib/Routable.js' => '6a18c42e', 'rsrc/externals/javelin/lib/Router.js' => '32755edb', 'rsrc/externals/javelin/lib/Scrollbar.js' => 'a43ae2ae', - 'rsrc/externals/javelin/lib/Sound.js' => 'e562708c', + 'rsrc/externals/javelin/lib/Sound.js' => 'd4cc2d2a', 'rsrc/externals/javelin/lib/URI.js' => '2e255291', 'rsrc/externals/javelin/lib/Vector.js' => 'e9c80beb', 'rsrc/externals/javelin/lib/WebSocket.js' => 'fdc13e4e', @@ -409,7 +409,7 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '65afb173', + 'rsrc/js/application/projects/WorkboardBoard.js' => '3ba8e6ad', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', @@ -418,7 +418,7 @@ return array( 'rsrc/js/application/projects/WorkboardHeader.js' => '111bfd2d', 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'ebe83a6b', 'rsrc/js/application/projects/WorkboardOrderTemplate.js' => '03e8891f', - 'rsrc/js/application/projects/behavior-project-boards.js' => '8512e4ea', + 'rsrc/js/application/projects/behavior-project-boards.js' => 'aad45445', 'rsrc/js/application/projects/behavior-project-create.js' => '34c53422', 'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68', @@ -664,7 +664,7 @@ return array( 'javelin-behavior-phuix-example' => 'c2c500a7', 'javelin-behavior-policy-control' => '0eaa33a9', 'javelin-behavior-policy-rule-editor' => '9347f172', - 'javelin-behavior-project-boards' => '8512e4ea', + 'javelin-behavior-project-boards' => 'aad45445', 'javelin-behavior-project-create' => '34c53422', 'javelin-behavior-quicksand-blacklist' => '5a6f6a06', 'javelin-behavior-read-only-warning' => 'b9109f8f', @@ -718,7 +718,7 @@ return array( 'javelin-routable' => '6a18c42e', 'javelin-router' => '32755edb', 'javelin-scrollbar' => 'a43ae2ae', - 'javelin-sound' => 'e562708c', + 'javelin-sound' => 'd4cc2d2a', 'javelin-stratcom' => '0889b835', 'javelin-tokenizer' => '89a1ae3a', 'javelin-typeahead' => 'a4356cde', @@ -737,7 +737,7 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '65afb173', + 'javelin-workboard-board' => '3ba8e6ad', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', 'javelin-workboard-column' => 'c3d24e63', @@ -1227,6 +1227,18 @@ return array( 'javelin-behavior', 'phabricator-prefab', ), + '3ba8e6ad' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), '3dc5ad43' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1456,18 +1468,6 @@ return array( '60cd9241' => array( 'javelin-behavior', ), - '65afb173' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), '65bb0011' => array( 'javelin-behavior', 'javelin-dom', @@ -1594,16 +1594,6 @@ return array( 'javelin-dom', 'javelin-vector', ), - '8512e4ea' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-util', - 'javelin-vector', - 'javelin-stratcom', - 'javelin-workflow', - 'javelin-workboard-controller', - 'javelin-workboard-drop-effect', - ), '87428eb2' => array( 'javelin-behavior', 'javelin-diffusion-locate-file-source', @@ -1848,6 +1838,16 @@ return array( 'javelin-dom', 'javelin-util', ), + 'aad45445' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'javelin-vector', + 'javelin-stratcom', + 'javelin-workflow', + 'javelin-workboard-controller', + 'javelin-workboard-drop-effect', + ), 'ab85e184' => array( 'javelin-install', 'javelin-dom', @@ -2041,6 +2041,9 @@ return array( 'd3799cb4' => array( 'javelin-install', ), + 'd4cc2d2a' => array( + 'javelin-install', + ), 'd8a86cfb' => array( 'javelin-behavior', 'javelin-dom', @@ -2075,9 +2078,6 @@ return array( 'javelin-dom', 'javelin-history', ), - 'e562708c' => array( - 'javelin-install', - ), 'e5bdb730' => array( 'javelin-behavior', 'javelin-stratcom', diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index a4edf3d246..f56c616545 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4183,6 +4183,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerManiphestStatusRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php', 'PhabricatorProjectTriggerNameTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php', 'PhabricatorProjectTriggerPHIDType' => 'applications/project/phid/PhabricatorProjectTriggerPHIDType.php', + 'PhabricatorProjectTriggerPlaySoundRule' => 'applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php', 'PhabricatorProjectTriggerQuery' => 'applications/project/query/PhabricatorProjectTriggerQuery.php', 'PhabricatorProjectTriggerRule' => 'applications/project/trigger/PhabricatorProjectTriggerRule.php', 'PhabricatorProjectTriggerRuleRecord' => 'applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php', @@ -10317,6 +10318,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerManiphestStatusRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerNameTransaction' => 'PhabricatorProjectTriggerTransactionType', 'PhabricatorProjectTriggerPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorProjectTriggerPlaySoundRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorProjectTriggerRule' => 'Phobject', 'PhabricatorProjectTriggerRuleRecord' => 'Phobject', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 4e4ff81b4e..e8a47d362a 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -542,6 +542,7 @@ final class PhabricatorProjectBoardViewController $templates = array(); $all_tasks = array(); $column_templates = array(); + $sounds = array(); foreach ($visible_columns as $column_phid => $column) { $column_tasks = $column_phids[$column_phid]; @@ -629,6 +630,10 @@ final class PhabricatorProjectBoardViewController if ($trigger) { $preview_effect = $trigger->getPreviewEffect() ->toDictionary(); + + foreach ($trigger->getSoundEffects() as $sound) { + $sounds[] = $sound; + } } } @@ -685,6 +690,7 @@ final class PhabricatorProjectBoardViewController 'boardID' => $board_id, 'projectPHID' => $project->getPHID(), + 'preloadSounds' => $sounds, ); $this->initBehavior('project-boards', $behavior_config); diff --git a/src/applications/project/controller/PhabricatorProjectController.php b/src/applications/project/controller/PhabricatorProjectController.php index 850dfa2268..c28ace305c 100644 --- a/src/applications/project/controller/PhabricatorProjectController.php +++ b/src/applications/project/controller/PhabricatorProjectController.php @@ -152,7 +152,8 @@ abstract class PhabricatorProjectController extends PhabricatorController { protected function newCardResponse( $board_phid, $object_phid, - PhabricatorProjectColumnOrder $ordering = null) { + PhabricatorProjectColumnOrder $ordering = null, + $sounds = array()) { $viewer = $this->getViewer(); @@ -166,7 +167,8 @@ abstract class PhabricatorProjectController extends PhabricatorController { ->setViewer($viewer) ->setBoardPHID($board_phid) ->setObjectPHID($object_phid) - ->setVisiblePHIDs($visible_phids); + ->setVisiblePHIDs($visible_phids) + ->setSounds($sounds); if ($ordering) { $engine->setOrdering($ordering); diff --git a/src/applications/project/controller/PhabricatorProjectMoveController.php b/src/applications/project/controller/PhabricatorProjectMoveController.php index 71588754c0..950b1e90cd 100644 --- a/src/applications/project/controller/PhabricatorProjectMoveController.php +++ b/src/applications/project/controller/PhabricatorProjectMoveController.php @@ -111,6 +111,7 @@ final class PhabricatorProjectMoveController $xactions[] = $header_xaction; } + $sounds = array(); if ($column->canHaveTrigger()) { $trigger = $column->getTrigger(); if ($trigger) { @@ -121,6 +122,10 @@ final class PhabricatorProjectMoveController foreach ($trigger_xactions as $trigger_xaction) { $xactions[] = $trigger_xaction; } + + foreach ($trigger->getSoundEffects() as $effect) { + $sounds[] = $effect; + } } } @@ -133,7 +138,11 @@ final class PhabricatorProjectMoveController $editor->applyTransactions($object, $xactions); - return $this->newCardResponse($board_phid, $object_phid, $ordering); + return $this->newCardResponse( + $board_phid, + $object_phid, + $ordering, + $sounds); } } diff --git a/src/applications/project/engine/PhabricatorBoardResponseEngine.php b/src/applications/project/engine/PhabricatorBoardResponseEngine.php index fb5299a857..f22254e43a 100644 --- a/src/applications/project/engine/PhabricatorBoardResponseEngine.php +++ b/src/applications/project/engine/PhabricatorBoardResponseEngine.php @@ -7,6 +7,7 @@ final class PhabricatorBoardResponseEngine extends Phobject { private $objectPHID; private $visiblePHIDs; private $ordering; + private $sounds; public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; @@ -53,6 +54,15 @@ final class PhabricatorBoardResponseEngine extends Phobject { return $this->ordering; } + public function setSounds(array $sounds) { + $this->sounds = $sounds; + return $this; + } + + public function getSounds() { + return $this->sounds; + } + public function buildResponse() { $viewer = $this->getViewer(); $object_phid = $this->getObjectPHID(); @@ -150,6 +160,7 @@ final class PhabricatorBoardResponseEngine extends Phobject { 'columnMaps' => $natural, 'cards' => $cards, 'headers' => $headers, + 'sounds' => $this->getSounds(), ); return id(new AphrontAjaxResponse()) diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php index a499f0bcf8..bac3927b74 100644 --- a/src/applications/project/storage/PhabricatorProjectTrigger.php +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -245,6 +245,18 @@ final class PhabricatorProjectTrigger ->setContent($header); } + public function getSoundEffects() { + $sounds = array(); + + foreach ($this->getTriggerRules() as $rule) { + foreach ($rule->getSoundEffects() as $effect) { + $sounds[] = $effect; + } + } + + return $sounds; + } + /* -( PhabricatorApplicationTransactionInterface )------------------------- */ diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php new file mode 100644 index 0000000000..ef19b504ef --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php @@ -0,0 +1,122 @@ +newEffect() + ->setIcon($sound_icon) + ->setColor($sound_color) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(self::getSoundMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = self::getSoundMap(); + $map = ipull($map, 'name'); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Play Sound'); + } + + public function getRuleViewDescription($value) { + $sound_name = self::getSoundName($value); + + return pht( + 'Play sound %s.', + phutil_tag('strong', array(), $sound_name)); + } + + public function getRuleViewIcon($value) { + $sound_icon = 'fa-volume-up'; + $sound_color = 'blue'; + + return id(new PHUIIconView()) + ->setIcon($sound_icon, $sound_color); + } + + private static function getSoundName($value) { + $map = self::getSoundMap(); + $spec = idx($map, $value, array()); + return idx($spec, 'name', $value); + } + + private static function getSoundMap() { + return array( + 'bing' => array( + 'name' => pht('Bing'), + 'uri' => celerity_get_resource_uri('/rsrc/audio/basic/bing.mp3'), + ), + 'glass' => array( + 'name' => pht('Glass'), + 'uri' => celerity_get_resource_uri('/rsrc/audio/basic/ting.mp3'), + ), + ); + } + + public function getSoundEffects() { + $value = $this->getValue(); + + $map = self::getSoundMap(); + $spec = idx($map, $value, array()); + + $uris = array(); + if (isset($spec['uri'])) { + $uris[] = $spec['uri']; + } + + return $uris; + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php index c75c15a1ab..ae2b3ee092 100644 --- a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -60,6 +60,10 @@ abstract class PhabricatorProjectTriggerRule return null; } + public function getSoundEffects() { + return array(); + } + final public function getDropTransactions($object, $value) { return $this->newDropTransactions($object, $value); } diff --git a/webroot/rsrc/externals/javelin/lib/Sound.js b/webroot/rsrc/externals/javelin/lib/Sound.js index accbe3d29b..68181560ff 100644 --- a/webroot/rsrc/externals/javelin/lib/Sound.js +++ b/webroot/rsrc/externals/javelin/lib/Sound.js @@ -8,31 +8,75 @@ JX.install('Sound', { statics: { _sounds: {}, + _queue: [], + _playingQueue: false, load: function(uri) { var self = JX.Sound; if (!(uri in self._sounds)) { - self._sounds[uri] = JX.$N( + var audio = JX.$N( 'audio', { src: uri, preload: 'auto' }); + + // In Safari, it isn't good enough to just load a sound in response + // to a click: we must also play it. Once we've played it once, we + // can continue to play it freely. + + // Play the sound, then immediately pause it. This rejects the "play()" + // promise but marks the audio as playable, so our "play()" method will + // work correctly later. + if (window.webkitAudioContext) { + audio.play().then(JX.bag, JX.bag); + audio.pause(); + } + + self._sounds[uri] = audio; } }, - play: function(uri) { + play: function(uri, callback) { var self = JX.Sound; self.load(uri); var sound = self._sounds[uri]; try { - sound.play(); + sound.onended = callback || JX.bag; + sound.play().then(JX.bag, callback || JX.bag); } catch (ex) { JX.log(ex); } + }, + + queue: function(uri) { + var self = JX.Sound; + self._queue.push(uri); + self._playQueue(); + }, + + _playQueue: function() { + var self = JX.Sound; + if (self._playingQueue) { + return; + } + self._playingQueue = true; + self._nextQueue(); + }, + + _nextQueue: function() { + var self = JX.Sound; + if (self._queue.length) { + var next = self._queue[0]; + self._queue.splice(0, 1); + self.play(next, self._nextQueue); + } else { + self._playingQueue = false; + } } + } }); diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index a7786a86f4..6add658259 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -529,6 +529,11 @@ JX.install('WorkboardBoard', { this.updateCard(response); + var sounds = response.sounds || []; + for (var ii = 0; ii < sounds.length; ii++) { + JX.Sound.queue(sounds[ii]); + } + list.unlock(); }, diff --git a/webroot/rsrc/js/application/projects/behavior-project-boards.js b/webroot/rsrc/js/application/projects/behavior-project-boards.js index daec59155f..bba6db7a49 100644 --- a/webroot/rsrc/js/application/projects/behavior-project-boards.js +++ b/webroot/rsrc/js/application/projects/behavior-project-boards.js @@ -166,4 +166,16 @@ JX.behavior('project-boards', function(config, statics) { board.start(); + // In Safari, we can only play sounds that we've already loaded, and we can + // only load them in response to an explicit user interaction like a click. + var sounds = config.preloadSounds; + var listener = JX.Stratcom.listen('mousedown', null, function() { + for (var ii = 0; ii < sounds.length; ii++) { + JX.Sound.load(sounds[ii]); + } + + // Remove this callback once it has run once. + listener.remove(); + }); + }); From 47856dc93f7ee81b1527c09eb26014c6a0cf7d61 Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 07:30:02 -0700 Subject: [PATCH 26/52] Track how many columns use a particular trigger Summary: Ref T5474. In 99% of cases, a separate "archived/active" status for triggers probably doesn't make much sense: there's not much reason to ever disable/archive a trigger explcitly, and the archival rule is really just "is this trigger used by anything?". (The one reason I can think of to disable a trigger manually is because you want to put something in a column and skip trigger rules, but you can already do this from the task detail page anyway, and disabling the trigger globally is a bad way to accomplish this if it's in use by other columns.) Instead of adding a separate "status", just track how many columns a trigger is used by and consider it "inactive" if it is not used by any active columns. Test Plan: This is slightly hard to test exhaustively since you can't share a trigger across multiple columns right now, but: rebuild indexes, poked around the trigger list and trigger details, added/removed triggers. Reviewers: amckinley Reviewed By: amckinley Subscribers: PHID-OPKG-gm6ozazyms6q6i22gyam Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20308 --- .../20190322.triggers.01.usage.sql | 8 ++ src/__phutil_library_map__.php | 5 ++ ...habricatorProjectTriggerViewController.php | 27 +++++- .../PhabricatorProjectTriggerEditor.php | 4 + ...rojectTriggerUsageIndexEngineExtension.php | 69 +++++++++++++++ .../query/PhabricatorProjectTriggerQuery.php | 88 ++++++++++++++++++- .../PhabricatorProjectTriggerSearchEngine.php | 88 ++++++++++++++++++- .../storage/PhabricatorProjectTrigger.php | 18 ++++ .../PhabricatorProjectTriggerUsage.php | 28 ++++++ ...bricatorProjectColumnStatusTransaction.php | 9 ++ ...ricatorProjectColumnTriggerTransaction.php | 19 ++++ ...abricatorSearchManagementIndexWorkflow.php | 8 +- 12 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 resources/sql/autopatches/20190322.triggers.01.usage.sql create mode 100644 src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php create mode 100644 src/applications/project/storage/PhabricatorProjectTriggerUsage.php diff --git a/resources/sql/autopatches/20190322.triggers.01.usage.sql b/resources/sql/autopatches/20190322.triggers.01.usage.sql new file mode 100644 index 0000000000..643ebbbfff --- /dev/null +++ b/resources/sql/autopatches/20190322.triggers.01.usage.sql @@ -0,0 +1,8 @@ +CREATE TABLE {$NAMESPACE}_project.project_triggerusage ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + triggerPHID VARBINARY(64) NOT NULL, + examplePHID VARBINARY(64), + columnCount INT UNSIGNED NOT NULL, + activeColumnCount INT UNSIGNED NOT NULL, + UNIQUE KEY `key_trigger` (triggerPHID) +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index f56c616545..ba02f45406 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4193,6 +4193,8 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerTransactionQuery' => 'applications/project/query/PhabricatorProjectTriggerTransactionQuery.php', 'PhabricatorProjectTriggerTransactionType' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php', 'PhabricatorProjectTriggerUnknownRule' => 'applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php', + 'PhabricatorProjectTriggerUsage' => 'applications/project/storage/PhabricatorProjectTriggerUsage.php', + 'PhabricatorProjectTriggerUsageIndexEngineExtension' => 'applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php', 'PhabricatorProjectTriggerViewController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php', 'PhabricatorProjectTypeTransaction' => 'applications/project/xaction/PhabricatorProjectTypeTransaction.php', 'PhabricatorProjectUIEventListener' => 'applications/project/events/PhabricatorProjectUIEventListener.php', @@ -10307,6 +10309,7 @@ phutil_register_library_map(array( 'PhabricatorProjectDAO', 'PhabricatorApplicationTransactionInterface', 'PhabricatorPolicyInterface', + 'PhabricatorIndexableInterface', 'PhabricatorDestructibleInterface', ), 'PhabricatorProjectTriggerController' => 'PhabricatorProjectController', @@ -10328,6 +10331,8 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProjectTriggerTransactionType' => 'PhabricatorModularTransactionType', 'PhabricatorProjectTriggerUnknownRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerUsage' => 'PhabricatorProjectDAO', + 'PhabricatorProjectTriggerUsageIndexEngineExtension' => 'PhabricatorIndexEngineExtension', 'PhabricatorProjectTriggerViewController' => 'PhabricatorProjectTriggerController', 'PhabricatorProjectTypeTransaction' => 'PhabricatorProjectTransactionType', 'PhabricatorProjectUIEventListener' => 'PhabricatorEventListener', diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php index 2750adc2ea..e18419fedb 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php @@ -71,17 +71,23 @@ final class PhabricatorProjectTriggerViewController // so we load only the columns they can actually see, but have a list of // all the impacted column PHIDs. + // (We're also exposing the status of columns the user might not be able + // to see. This technically violates policy, but the trigger usage table + // hints at it anyway and it seems unlikely to ever have any security + // impact, but is useful in assessing whether a trigger is really in use + // or not.) + $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $all_columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($omnipotent_viewer) ->withTriggerPHIDs(array($trigger->getPHID())) ->execute(); - $column_phids = mpull($all_columns, 'getPHID'); + $column_map = mpull($all_columns, 'getStatus', 'getPHID'); - if ($column_phids) { + if ($column_map) { $visible_columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) - ->withPHIDs($column_phids) + ->withPHIDs(array_keys($column_map)) ->execute(); $visible_columns = mpull($visible_columns, null, 'getPHID'); } else { @@ -89,7 +95,7 @@ final class PhabricatorProjectTriggerViewController } $rows = array(); - foreach ($column_phids as $column_phid) { + foreach ($column_map as $column_phid => $column_status) { $column = idx($visible_columns, $column_phid); if ($column) { @@ -113,7 +119,18 @@ final class PhabricatorProjectTriggerViewController $column_name = phutil_tag('em', array(), pht('Restricted Column')); } + if ($column_status == PhabricatorProjectColumn::STATUS_ACTIVE) { + $status_icon = id(new PHUIIconView()) + ->setIcon('fa-columns', 'blue') + ->setTooltip(pht('Active Column')); + } else { + $status_icon = id(new PHUIIconView()) + ->setIcon('fa-eye-slash', 'grey') + ->setTooltip(pht('Hidden Column')); + } + $rows[] = array( + $status_icon, $project_name, $column_name, ); @@ -123,11 +140,13 @@ final class PhabricatorProjectTriggerViewController ->setNoDataString(pht('This trigger is not used by any columns.')) ->setHeaders( array( + null, pht('Project'), pht('Column'), )) ->setColumnClasses( array( + null, null, 'wide pri', )); diff --git a/src/applications/project/editor/PhabricatorProjectTriggerEditor.php b/src/applications/project/editor/PhabricatorProjectTriggerEditor.php index 20098fa370..9014fd6f16 100644 --- a/src/applications/project/editor/PhabricatorProjectTriggerEditor.php +++ b/src/applications/project/editor/PhabricatorProjectTriggerEditor.php @@ -27,4 +27,8 @@ final class PhabricatorProjectTriggerEditor return $types; } + protected function supportsSearch() { + return true; + } + } diff --git a/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php b/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php new file mode 100644 index 0000000000..b50c51fba6 --- /dev/null +++ b/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php @@ -0,0 +1,69 @@ +establishConnection('w'); + + $active_statuses = array( + PhabricatorProjectColumn::STATUS_ACTIVE, + ); + + // Select summary information to populate the usage index. When picking + // an "examplePHID", we try to pick an active column. + $row = queryfx_one( + $conn_w, + 'SELECT phid, COUNT(*) N, SUM(IF(status IN (%Ls), 1, 0)) M FROM %R + WHERE triggerPHID = %s + ORDER BY IF(status IN (%Ls), 1, 0) DESC, id ASC', + $active_statuses, + $column_table, + $object->getPHID(), + $active_statuses); + if ($row) { + $example_phid = $row['phid']; + $column_count = $row['N']; + $active_count = $row['M']; + } else { + $example_phid = null; + $column_count = 0; + $active_count = 0; + } + + queryfx( + $conn_w, + 'INSERT INTO %R (triggerPHID, examplePHID, columnCount, activeColumnCount) + VALUES (%s, %ns, %d, %d) + ON DUPLICATE KEY UPDATE + examplePHID = VALUES(examplePHID), + columnCount = VALUES(columnCount), + activeColumnCount = VALUES(activeColumnCount)', + $usage_table, + $object->getPHID(), + $example_phid, + $column_count, + $active_count); + } + +} diff --git a/src/applications/project/query/PhabricatorProjectTriggerQuery.php b/src/applications/project/query/PhabricatorProjectTriggerQuery.php index e3fab5b3d0..452e3e53f1 100644 --- a/src/applications/project/query/PhabricatorProjectTriggerQuery.php +++ b/src/applications/project/query/PhabricatorProjectTriggerQuery.php @@ -5,6 +5,10 @@ final class PhabricatorProjectTriggerQuery private $ids; private $phids; + private $activeColumnMin; + private $activeColumnMax; + + private $needUsage; public function withIDs(array $ids) { $this->ids = $ids; @@ -16,6 +20,17 @@ final class PhabricatorProjectTriggerQuery return $this; } + public function needUsage($need_usage) { + $this->needUsage = $need_usage; + return $this; + } + + public function withActiveColumnCountBetween($min, $max) { + $this->activeColumnMin = $min; + $this->activeColumnMax = $max; + return $this; + } + public function newResultObject() { return new PhabricatorProjectTrigger(); } @@ -30,22 +45,91 @@ final class PhabricatorProjectTriggerQuery if ($this->ids !== null) { $where[] = qsprintf( $conn, - 'id IN (%Ld)', + 'trigger.id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, - 'phid IN (%Ls)', + 'trigger.phid IN (%Ls)', $this->phids); } + if ($this->activeColumnMin !== null) { + $where[] = qsprintf( + $conn, + 'trigger_usage.activeColumnCount >= %d', + $this->activeColumnMin); + } + + if ($this->activeColumnMax !== null) { + $where[] = qsprintf( + $conn, + 'trigger_usage.activeColumnCount <= %d', + $this->activeColumnMax); + } + return $where; } + protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { + $joins = parent::buildJoinClauseParts($conn); + + if ($this->shouldJoinUsageTable()) { + $joins[] = qsprintf( + $conn, + 'JOIN %R trigger_usage ON trigger.phid = trigger_usage.triggerPHID', + new PhabricatorProjectTriggerUsage()); + } + + return $joins; + } + + private function shouldJoinUsageTable() { + if ($this->activeColumnMin !== null) { + return true; + } + + if ($this->activeColumnMax !== null) { + return true; + } + + return false; + } + + protected function didFilterPage(array $triggers) { + if ($this->needUsage) { + $usage_map = id(new PhabricatorProjectTriggerUsage())->loadAllWhere( + 'triggerPHID IN (%Ls)', + mpull($triggers, 'getPHID')); + $usage_map = mpull($usage_map, null, 'getTriggerPHID'); + + foreach ($triggers as $trigger) { + $trigger_phid = $trigger->getPHID(); + + $usage = idx($usage_map, $trigger_phid); + if (!$usage) { + $usage = id(new PhabricatorProjectTriggerUsage()) + ->setTriggerPHID($trigger_phid) + ->setExamplePHID(null) + ->setColumnCount(0) + ->setActiveColumnCount(0); + } + + $trigger->attachUsage($usage); + } + } + + return $triggers; + } + public function getQueryApplicationClass() { return 'PhabricatorProjectApplication'; } + protected function getPrimaryTableAlias() { + return 'trigger'; + } + } diff --git a/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php b/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php index 6c6a417723..a178ed3e6c 100644 --- a/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php +++ b/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php @@ -12,16 +12,33 @@ final class PhabricatorProjectTriggerSearchEngine } public function newQuery() { - return new PhabricatorProjectTriggerQuery(); + return id(new PhabricatorProjectTriggerQuery()) + ->needUsage(true); } protected function buildCustomSearchFields() { - return array(); + return array( + id(new PhabricatorSearchThreeStateField()) + ->setLabel(pht('Active')) + ->setKey('isActive') + ->setOptions( + pht('(Show All)'), + pht('Show Only Active Triggers'), + pht('Show Only Inactive Triggers')), + ); } protected function buildQueryFromParameters(array $map) { $query = $this->newQuery(); + if ($map['isActive'] !== null) { + if ($map['isActive']) { + $query->withActiveColumnCountBetween(1, null); + } else { + $query->withActiveColumnCountBetween(null, 0); + } + } + return $query; } @@ -32,7 +49,8 @@ final class PhabricatorProjectTriggerSearchEngine protected function getBuiltinQueryNames() { $names = array(); - $names['all'] = pht('All'); + $names['active'] = pht('Active Triggers'); + $names['all'] = pht('All Triggers'); return $names; } @@ -42,6 +60,8 @@ final class PhabricatorProjectTriggerSearchEngine $query->setQueryKey($query_key); switch ($query_key) { + case 'active': + return $query->setParameter('isActive', true); case 'all': return $query; } @@ -56,13 +76,73 @@ final class PhabricatorProjectTriggerSearchEngine assert_instances_of($triggers, 'PhabricatorProjectTrigger'); $viewer = $this->requireViewer(); + $example_phids = array(); + foreach ($triggers as $trigger) { + $example_phid = $trigger->getUsage()->getExamplePHID(); + if ($example_phid) { + $example_phids[] = $example_phid; + } + } + + $handles = $viewer->loadHandles($example_phids); + $list = id(new PHUIObjectItemListView()) ->setViewer($viewer); foreach ($triggers as $trigger) { + $usage = $trigger->getUsage(); + + $column_handle = null; + $have_column = false; + $example_phid = $usage->getExamplePHID(); + if ($example_phid) { + $column_handle = $handles[$example_phid]; + if ($column_handle->isComplete()) { + if (!$column_handle->getPolicyFiltered()) { + $have_column = true; + } + } + } + + $column_count = $usage->getColumnCount(); + $active_count = $usage->getActiveColumnCount(); + + if ($have_column) { + if ($active_count > 1) { + $usage_description = pht( + 'Used on %s and %s other active column(s).', + $column_handle->renderLink(), + new PhutilNumber($active_count - 1)); + } else if ($column_count > 1) { + $usage_description = pht( + 'Used on %s and %s other column(s).', + $column_handle->renderLink(), + new PhutilNumber($column_count - 1)); + } else { + $usage_description = pht( + 'Used on %s.', + $column_handle->renderLink()); + } + } else { + if ($active_count) { + $usage_description = pht( + 'Used on %s active column(s).', + new PhutilNumber($active_count)); + } else if ($column_count) { + $usage_description = pht( + 'Used on %s column(s).', + new PhutilNumber($column_count)); + } else { + $usage_description = pht( + 'Unused trigger.'); + } + } + $item = id(new PHUIObjectItemView()) ->setObjectName($trigger->getObjectName()) ->setHeader($trigger->getDisplayName()) - ->setHref($trigger->getURI()); + ->setHref($trigger->getURI()) + ->addAttribute($usage_description) + ->setDisabled(!$active_count); $list->addItem($item); } diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php index bac3927b74..625dc7ffd8 100644 --- a/src/applications/project/storage/PhabricatorProjectTrigger.php +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -5,6 +5,7 @@ final class PhabricatorProjectTrigger implements PhabricatorApplicationTransactionInterface, PhabricatorPolicyInterface, + PhabricatorIndexableInterface, PhabricatorDestructibleInterface { protected $name; @@ -12,6 +13,7 @@ final class PhabricatorProjectTrigger protected $editPolicy; private $triggerRules; + private $usage = self::ATTACHABLE; public static function initializeNewTrigger() { $default_edit = PhabricatorPolicies::POLICY_USER; @@ -257,6 +259,15 @@ final class PhabricatorProjectTrigger return $sounds; } + public function getUsage() { + return $this->assertAttached($this->usage); + } + + public function attachUsage(PhabricatorProjectTriggerUsage $usage) { + $this->usage = $usage; + return $this; + } + /* -( PhabricatorApplicationTransactionInterface )------------------------- */ @@ -310,6 +321,13 @@ final class PhabricatorProjectTrigger new PhabricatorProjectColumn(), $this->getPHID()); + // Remove the usage index row for this trigger, if one exists. + queryfx( + $conn, + 'DELETE FROM %R WHERE triggerPHID = %s', + new PhabricatorProjectTriggerUsage(), + $this->getPHID()); + $this->delete(); $this->saveTransaction(); diff --git a/src/applications/project/storage/PhabricatorProjectTriggerUsage.php b/src/applications/project/storage/PhabricatorProjectTriggerUsage.php new file mode 100644 index 0000000000..982b8ecf55 --- /dev/null +++ b/src/applications/project/storage/PhabricatorProjectTriggerUsage.php @@ -0,0 +1,28 @@ + false, + self::CONFIG_COLUMN_SCHEMA => array( + 'examplePHID' => 'phid?', + 'columnCount' => 'uint32', + 'activeColumnCount' => 'uint32', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_trigger' => array( + 'columns' => array('triggerPHID'), + 'unique' => true, + ), + ), + ) + parent::getConfiguration(); + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php index 7606c72562..7aab57c8e6 100644 --- a/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php @@ -13,6 +13,15 @@ final class PhabricatorProjectColumnStatusTransaction $object->setStatus($value); } + public function applyExternalEffects($object, $value) { + // Update the trigger usage index, which cares about whether columns are + // active or not. + $trigger_phid = $object->getTriggerPHID(); + if ($trigger_phid) { + PhabricatorSearchWorker::queueDocumentForIndexing($trigger_phid); + } + } + public function getTitle() { $new = $this->getNewValue(); diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php index 78e2451bd1..5339699de3 100644 --- a/src/applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php @@ -13,6 +13,25 @@ final class PhabricatorProjectColumnTriggerTransaction $object->setTriggerPHID($value); } + public function applyExternalEffects($object, $value) { + // After we change the trigger attached to a column, update the search + // indexes for the old and new triggers so we update the usage index. + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $column_phids = array(); + if ($old) { + $column_phids[] = $old; + } + if ($new) { + $column_phids[] = $new; + } + + foreach ($column_phids as $phid) { + PhabricatorSearchWorker::queueDocumentForIndexing($phid); + } + } + public function getTitle() { $old = $this->getOldValue(); $new = $this->getNewValue(); diff --git a/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php b/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php index 99ee3a3123..984eeae5fb 100644 --- a/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php +++ b/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php @@ -136,7 +136,13 @@ final class PhabricatorSearchManagementIndexWorkflow if ($track_skips) { $new_versions = $this->loadIndexVersions($phid); - if ($old_versions !== $new_versions) { + + if (!$old_versions && !$new_versions) { + // If the document doesn't use an index version, both the lists + // of versions will be empty. We still rebuild the index in this + // case. + $count_updated++; + } else if ($old_versions !== $new_versions) { $count_updated++; } else { $count_skipped++; From c53ed72e4cf5bdaf18967d793f8898c709911bbe Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 22 Mar 2019 15:29:47 -0700 Subject: [PATCH 27/52] Provide a clearer UI for "view all results" in partial result panels Summary: In some cases, we show a limited number of one type of object somewhere else, like "Recent Such-And-Such" or "Herald Rules Which Use This" or whatever. We don't do a very good job of communicating that these are partial lists, or how to see all the results. Usually there's a button in the upper right, which is fine, but this could be better. Add an explicit "more stuff" button that shows up where a pager would appear and makes it clear that (a) the list is partial; and (b) you can click the button to see everything. Test Plan: {F6302793} Reviewers: amckinley Reviewed By: amckinley Subscribers: PHID-OPKG-gm6ozazyms6q6i22gyam Differential Revision: https://secure.phabricator.com/D20315 --- resources/celerity/map.php | 6 ++--- .../HarbormasterPlanViewController.php | 22 +++++++++++++++-- src/view/phui/PHUIObjectItemListView.php | 24 +++++++++++++++++++ .../phui/object-item/phui-oi-list-view.css | 6 +++++ 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 5dd506784a..7b39ee861a 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -9,7 +9,7 @@ return array( 'names' => array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', - 'core.pkg.css' => 'b797945d', + 'core.pkg.css' => '2dd936d6', 'core.pkg.js' => 'eb53fc5b', 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', @@ -132,7 +132,7 @@ return array( 'rsrc/css/phui/object-item/phui-oi-color.css' => 'b517bfa0', 'rsrc/css/phui/object-item/phui-oi-drag-ui.css' => 'da15d3dc', 'rsrc/css/phui/object-item/phui-oi-flush-ui.css' => '490e2e2e', - 'rsrc/css/phui/object-item/phui-oi-list-view.css' => '909f3844', + 'rsrc/css/phui/object-item/phui-oi-list-view.css' => 'a65865a7', 'rsrc/css/phui/object-item/phui-oi-simple-ui.css' => '6a30fa46', 'rsrc/css/phui/phui-action-list.css' => 'c4972757', 'rsrc/css/phui/phui-action-panel.css' => '6c386cbf', @@ -853,7 +853,7 @@ return array( 'phui-oi-color-css' => 'b517bfa0', 'phui-oi-drag-ui-css' => 'da15d3dc', 'phui-oi-flush-ui-css' => '490e2e2e', - 'phui-oi-list-view-css' => '909f3844', + 'phui-oi-list-view-css' => 'a65865a7', 'phui-oi-simple-ui-css' => '6a30fa46', 'phui-pager-css' => 'd022c7ad', 'phui-pinboard-view-css' => '1f08f5d8', diff --git a/src/applications/harbormaster/controller/HarbormasterPlanViewController.php b/src/applications/harbormaster/controller/HarbormasterPlanViewController.php index a9af90f2a5..4f2b70fcaf 100644 --- a/src/applications/harbormaster/controller/HarbormasterPlanViewController.php +++ b/src/applications/harbormaster/controller/HarbormasterPlanViewController.php @@ -455,12 +455,16 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { private function newBuildsView(HarbormasterBuildPlan $plan) { $viewer = $this->getViewer(); + $limit = 10; $builds = id(new HarbormasterBuildQuery()) ->setViewer($viewer) ->withBuildPlanPHIDs(array($plan->getPHID())) - ->setLimit(10) + ->setLimit($limit + 1) ->execute(); + $more_results = (count($builds) > $limit); + $builds = array_slice($builds, 0, $limit); + $list = id(new HarbormasterBuildView()) ->setViewer($viewer) ->setBuilds($builds) @@ -472,6 +476,11 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { $this->getApplicationURI('/build/'), array('plan' => $plan->getPHID())); + if ($more_results) { + $list->newTailButton() + ->setHref($more_href); + } + $more_link = id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-list-ul') @@ -491,14 +500,18 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { private function newRulesView(HarbormasterBuildPlan $plan) { $viewer = $this->getViewer(); + $limit = 10; $rules = id(new HeraldRuleQuery()) ->setViewer($viewer) ->withDisabled(false) ->withAffectedObjectPHIDs(array($plan->getPHID())) ->needValidateAuthors(true) - ->setLimit(10) + ->setLimit($limit + 1) ->execute(); + $more_results = (count($rules) > $limit); + $rules = array_slice($rules, 0, $limit); + $list = id(new HeraldRuleListView()) ->setViewer($viewer) ->setRules($rules) @@ -510,6 +523,11 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { '/herald/', array('affectedPHID' => $plan->getPHID())); + if ($more_results) { + $list->newTailButton() + ->setHref($more_href); + } + $more_link = id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-list-ul') diff --git a/src/view/phui/PHUIObjectItemListView.php b/src/view/phui/PHUIObjectItemListView.php index 53e86382c2..fbc3904586 100644 --- a/src/view/phui/PHUIObjectItemListView.php +++ b/src/view/phui/PHUIObjectItemListView.php @@ -12,6 +12,7 @@ final class PHUIObjectItemListView extends AphrontTagView { private $drag; private $allowEmptyList; private $itemClass = 'phui-oi-standard'; + private $tail = array(); public function setAllowEmptyList($allow_empty_list) { $this->allowEmptyList = $allow_empty_list; @@ -72,6 +73,18 @@ final class PHUIObjectItemListView extends AphrontTagView { return 'ul'; } + public function newTailButton() { + $button = id(new PHUIButtonView()) + ->setTag('a') + ->setColor(PHUIButtonView::GREY) + ->setIcon('fa-chevron-down') + ->setText(pht('View All Results')); + + $this->tail[] = $button; + + return $button; + } + protected function getTagAttributes() { $classes = array(); $classes[] = 'phui-oi-list-view'; @@ -149,9 +162,20 @@ final class PHUIObjectItemListView extends AphrontTagView { $pager = $this->pager; } + $tail = array(); + foreach ($this->tail as $tail_item) { + $tail[] = phutil_tag( + 'li', + array( + 'class' => 'phui-oi-tail', + ), + $tail_item); + } + return array( $header, $items, + $tail, $pager, $this->renderChildren(), ); diff --git a/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css b/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css index 6f2421ca2f..67d0682aa7 100644 --- a/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css +++ b/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css @@ -720,3 +720,9 @@ ul.phui-oi-list-view .phui-oi-selectable .differential-revision-small .phui-icon-view { color: #6699ba; } + +.phui-oi-tail { + text-align: center; + padding: 8px 0; + background: linear-gradient({$lightbluebackground}, #fff 66%, #fff); +} From 686b03a1d59eb2059af3a6075a7092b6156a6135 Mon Sep 17 00:00:00 2001 From: epriestley Date: Mon, 25 Mar 2019 08:04:33 -0700 Subject: [PATCH 28/52] Dim the action drop preview element when the cursor approaches Summary: Depends on D20308. Ref T5474. The element which previews what will happen when you drop a task somewhere can cover the bottom part of the rightmost column on a workboard. To fix this, I'm trying to just fade it out if you put your cursor over it. I tried to do this in a simple way previously (":hover" + "opacity: 0.25") but it doesn't actually work because "pointer-events: none" stops ":hover" from working. Instead, do this in Javascript. This is a little more complicated but: it works; and we can do the fade when you get //near// the element instead of actually over it, which feels a little better. Test Plan: - Shrank window to fairly small size so that the preview could cover up stuff on the workboard. - Dragged a card toward the rightmost column. - Before: drop action preview covered some workboard stuff. - After: preview faded out as my cursor approached. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T5474 Differential Revision: https://secure.phabricator.com/D20320 --- resources/celerity/map.php | 38 +++++++++--------- .../css/phui/workboards/phui-workpanel.css | 20 ++++++++-- .../js/application/projects/WorkboardBoard.js | 40 +++++++++++++++++++ 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 7b39ee861a..586e568f64 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -179,7 +179,7 @@ return array( 'rsrc/css/phui/workboards/phui-workboard-color.css' => 'e86de308', 'rsrc/css/phui/workboards/phui-workboard.css' => '74fc9d98', 'rsrc/css/phui/workboards/phui-workcard.css' => '9e9eb0df', - 'rsrc/css/phui/workboards/phui-workpanel.css' => '4e4ec9f0', + 'rsrc/css/phui/workboards/phui-workpanel.css' => 'f43b8c7f', 'rsrc/css/sprite-login.css' => '18b368a6', 'rsrc/css/sprite-tokens.css' => 'f1896dc5', 'rsrc/css/syntax/syntax-default.css' => '055fc231', @@ -409,7 +409,7 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '3ba8e6ad', + 'rsrc/js/application/projects/WorkboardBoard.js' => '223af34e', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', @@ -737,7 +737,7 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '3ba8e6ad', + 'javelin-workboard-board' => '223af34e', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', 'javelin-workboard-column' => 'c3d24e63', @@ -869,7 +869,7 @@ return array( 'phui-workboard-color-css' => 'e86de308', 'phui-workboard-view-css' => '74fc9d98', 'phui-workcard-view-css' => '9e9eb0df', - 'phui-workpanel-view-css' => '4e4ec9f0', + 'phui-workpanel-view-css' => 'f43b8c7f', 'phuix-action-list-view' => 'c68f183f', 'phuix-action-view' => 'aaa08f3b', 'phuix-autocomplete' => '8f139ef0', @@ -1073,6 +1073,18 @@ return array( 'javelin-behavior', 'javelin-request', ), + '223af34e' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), '225bbb98' => array( 'javelin-install', 'javelin-reactor', @@ -1227,18 +1239,6 @@ return array( 'javelin-behavior', 'phabricator-prefab', ), - '3ba8e6ad' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), '3dc5ad43' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1351,9 +1351,6 @@ return array( 'phuix-icon-view', 'javelin-behavior-phabricator-gesture', ), - '4e4ec9f0' => array( - 'phui-workcard-view-css', - ), '4e61fa88' => array( 'javelin-behavior', 'javelin-aphlict', @@ -2144,6 +2141,9 @@ return array( 'phabricator-darklog', 'phabricator-darkmessage', ), + 'f43b8c7f' => array( + 'phui-workcard-view-css', + ), 'f51e9c17' => array( 'javelin-behavior', 'javelin-stratcom', diff --git a/webroot/rsrc/css/phui/workboards/phui-workpanel.css b/webroot/rsrc/css/phui/workboards/phui-workpanel.css index 97600ff5e6..5c0a62282b 100644 --- a/webroot/rsrc/css/phui/workboards/phui-workpanel.css +++ b/webroot/rsrc/css/phui/workboards/phui-workpanel.css @@ -189,10 +189,7 @@ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); border: 1px solid {$lightblueborder}; padding: 4px 0; -} - -.workboard-drop-preview:hover { - opacity: 0.25; + background: #fff; } .workboard-drop-preview li { @@ -225,3 +222,18 @@ background: {$blue}; color: #fff; } + +.workboard-drop-preview-fade { + animation: 0.1s workboard-drop-preview-fade-out; + opacity: 0.25; +} + +@keyframes workboard-drop-preview-fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0.25; + } +} diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index 6add658259..0cd8abb7d2 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -43,6 +43,8 @@ JX.install('WorkboardBoard', { _dropPreviewListNode: null, _previewPHID: null, _hidePreivew: false, + _previewPositionVector: null, + _previewDimState: false, getRoot: function() { return this._root; @@ -148,6 +150,39 @@ JX.install('WorkboardBoard', { var on_out = JX.bind(this, this._hideTriggerPreview); JX.Stratcom.listen('mouseover', 'trigger-preview', on_over); JX.Stratcom.listen('mouseout', 'trigger-preview', on_out); + + var on_move = JX.bind(this, this._dimPreview); + JX.Stratcom.listen('mousemove', null, on_move); + }, + + _dimPreview: function(e) { + var p = this._previewPositionVector; + if (!p) { + return; + } + + // When the mouse cursor gets near the drop preview element, fade it + // out so you can see through it. We can't do this with ":hover" because + // we disable cursor events. + + var cursor = JX.$V(e); + var margin = 64; + + var near_x = (cursor.x > (p.x - margin)); + var near_y = (cursor.y > (p.y - margin)); + var should_dim = (near_x && near_y); + + this._setPreviewDimState(should_dim); + }, + + _setPreviewDimState: function(is_dim) { + if (is_dim === this._previewDimState) { + return; + } + + this._previewDimState = is_dim; + var node = this._getDropPreviewNode(); + JX.DOM.alterClass(node, 'workboard-drop-preview-fade', is_dim); }, _showTriggerPreview: function(e) { @@ -325,6 +360,7 @@ JX.install('WorkboardBoard', { if (!effects.length) { JX.DOM.remove(node); + this._previewPositionVector = null; return; } @@ -336,6 +372,10 @@ JX.install('WorkboardBoard', { JX.DOM.setContent(this._getDropPreviewListNode(), items); document.body.appendChild(node); + + // Undim the drop preview element if it was previously dimmed. + this._setPreviewDimState(false); + this._previewPositionVector = JX.$V(node); }, _getDropPreviewNode: function() { From 6182193cf55a8eeb5fc75e54916dc90ccd944c88 Mon Sep 17 00:00:00 2001 From: epriestley Date: Mon, 25 Mar 2019 14:31:21 -0700 Subject: [PATCH 29/52] Give the "Code" tab in Diffusion more consistent (path-retaining) behavior Summary: Fixes T13270. In Diffusion, the "Code" tab is linked in a weird way that isn't consistent with the other tabs. Particularly, if you navigate to `x/y/z/` and toggle between the "Branches" and "History" tabs (or other tabs), you keep your path. If you click "Code", you lose your path. Instead, retain the path, so you can navigate somewhere and then toggle to/from the "Code" tab to get different views of the same path. Test Plan: Browed into a repository, clicked "History", clicked "Code", ended up back in the place I started. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13270 Differential Revision: https://secure.phabricator.com/D20323 --- src/applications/diffusion/controller/DiffusionController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/applications/diffusion/controller/DiffusionController.php b/src/applications/diffusion/controller/DiffusionController.php index a220ac05e0..5f4c304ebc 100644 --- a/src/applications/diffusion/controller/DiffusionController.php +++ b/src/applications/diffusion/controller/DiffusionController.php @@ -512,8 +512,7 @@ abstract class DiffusionController extends PhabricatorController { ->setIcon('fa-code') ->setHref($drequest->generateURI( array( - 'action' => 'branch', - 'path' => '/', + 'action' => 'browse', ))) ->setSelected($key == 'code')); From 6138e50962e62316f270a7a25c272eb47a92160a Mon Sep 17 00:00:00 2001 From: epriestley Date: Mon, 25 Mar 2019 10:12:10 -0700 Subject: [PATCH 30/52] When moving cards on workboards, treat before/after cards as optional hints, not strict requirements Summary: Depends on D20320. Ref T12175. Ref T13074. Currently, when you move a card between columns, the internal transaction takes exactly one `afterPHID` or `beforePHID` and moves the card before or after the specified card. This is a fairly strict interpretation and causes a number of practical issues, mostly because the user/client view of the board may be out of date and the card they're dragging before or after may no longer exist: another user might have moved or hidden it between the last client update and the current time. In T13074, we also run into a more subtle issue where a card that incorrectly appears in multiple columns fatals when dropped before or after itself. In all cases, a better behavior is just to complete the move and accept that the position may not end up exactly like the user specified. We could prompt the user instead: > You tried to drop this card after card X, but that card has moved since you last loaded the board. Reload the board and try again. ...but this is pretty hostile and probably rarely/never what the user wants. Instead, accept a list of before/after PHIDs and just try them until we find one that works, or accept a default position if none work. In essentially all cases, this means that the move "just works" like users expect it to instead of fataling in a confusing/disruptive/undesirable (but "technically correct") way. (A followup will make the client JS send more beforePHIDs/afterPHIDs so this works more often.) We could eventually add a "strict" mode in the API or something if there's some bot/API use case for precise behavior here, but I suspect none exist today or are (ever?) likely to exist in the future. Test Plan: - (T13074) Inserted two conflicting rows to put a card on two columns on the same board. Dropped one version of it underneath the other version. Before: confusing fatal. After: cards merge sensibly into one consistent card. - (T12175) Opened two views of a board. Moved card A to a different column on the first view. On the second view, dropped card B under card A (still showing in the old column). Before: confusing fatal. After: card ended up in the right column in approximately the right place, very reasonably. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13074, T12175 Differential Revision: https://secure.phabricator.com/D20321 --- resources/celerity/map.php | 28 +-- .../maniphest/editor/ManiphestEditEngine.php | 15 +- .../editor/ManiphestTransactionEditor.php | 166 +++++++++--------- .../PhabricatorProjectCoreTestCase.php | 18 +- .../PhabricatorProjectMoveController.php | 18 +- .../engine/PhabricatorBoardLayoutEngine.php | 136 +++++++------- .../js/application/projects/WorkboardBoard.js | 2 +- 7 files changed, 194 insertions(+), 189 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 586e568f64..972a5c2b56 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -409,7 +409,7 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '223af34e', + 'rsrc/js/application/projects/WorkboardBoard.js' => '106d870f', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', @@ -737,7 +737,7 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '223af34e', + 'javelin-workboard-board' => '106d870f', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', 'javelin-workboard-column' => 'c3d24e63', @@ -1015,6 +1015,18 @@ return array( 'javelin-workflow', 'phuix-icon-view', ), + '106d870f' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), '111bfd2d' => array( 'javelin-install', ), @@ -1073,18 +1085,6 @@ return array( 'javelin-behavior', 'javelin-request', ), - '223af34e' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), '225bbb98' => array( 'javelin-install', 'javelin-reactor', diff --git a/src/applications/maniphest/editor/ManiphestEditEngine.php b/src/applications/maniphest/editor/ManiphestEditEngine.php index 76c2276df0..2a8730d5c6 100644 --- a/src/applications/maniphest/editor/ManiphestEditEngine.php +++ b/src/applications/maniphest/editor/ManiphestEditEngine.php @@ -123,22 +123,23 @@ information about the move, including an optional specific position within the column. The target column should be identified as `columnPHID`, and you may select a -position by passing either `beforePHID` or `afterPHID`, specifying the PHID of -a task currently in the column that you want to move this task before or after: +position by passing either `beforePHIDs` or `afterPHIDs`, specifying the PHIDs +of tasks currently in the column that you want to move this task before or +after: ```lang=json [ { "columnPHID": "PHID-PCOL-4444", - "beforePHID": "PHID-TASK-5555" + "beforePHIDs": ["PHID-TASK-5555"] } ] ``` -Note that this affects only the "natural" position of the task. The task -position when the board is sorted by some other attribute (like priority) -depends on that attribute value: change a task's priority to move it on -priority-sorted boards. +When you specify multiple PHIDs, the task will be moved adjacent to the first +valid PHID found in either of the lists. This allows positional moves to +generally work as users expect even if the client view of the board has fallen +out of date and some of the nearby tasks have moved elsewhere. EODOCS ); diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 5cb7cf91ba..5198f44572 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -428,6 +428,7 @@ final class ManiphestTransactionEditor private function buildMoveTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { + $actor = $this->getActor(); $new = $xaction->getNewValue(); if (!is_array($new)) { @@ -435,7 +436,7 @@ final class ManiphestTransactionEditor $new = array($new); } - $nearby_phids = array(); + $relative_phids = array(); foreach ($new as $key => $value) { if (!is_array($value)) { $this->validateColumnPHID($value); @@ -448,35 +449,83 @@ final class ManiphestTransactionEditor $value, array( 'columnPHID' => 'string', + 'beforePHIDs' => 'optional list', + 'afterPHIDs' => 'optional list', + + // Deprecated older variations of "beforePHIDs" and "afterPHIDs". 'beforePHID' => 'optional string', 'afterPHID' => 'optional string', )); - $new[$key] = $value; - - if (!empty($value['beforePHID'])) { - $nearby_phids[] = $value['beforePHID']; - } + $value = $value + array( + 'beforePHIDs' => array(), + 'afterPHIDs' => array(), + ); + // Normalize the legacy keys "beforePHID" and "afterPHID" keys to the + // modern format. if (!empty($value['afterPHID'])) { - $nearby_phids[] = $value['afterPHID']; + if ($value['afterPHIDs']) { + throw new Exception( + pht( + 'Transaction specifies both "afterPHID" and "afterPHIDs". '. + 'Specify only "afterPHIDs".')); + } + $value['afterPHIDs'] = array($value['afterPHID']); + unset($value['afterPHID']); } + + if (isset($value['beforePHID'])) { + if ($value['beforePHIDs']) { + throw new Exception( + pht( + 'Transaction specifies both "beforePHID" and "beforePHIDs". '. + 'Specify only "beforePHIDs".')); + } + $value['beforePHIDs'] = array($value['beforePHID']); + unset($value['beforePHID']); + } + + foreach ($value['beforePHIDs'] as $phid) { + $relative_phids[] = $phid; + } + + foreach ($value['afterPHIDs'] as $phid) { + $relative_phids[] = $phid; + } + + $new[$key] = $value; } - if ($nearby_phids) { - $nearby_objects = id(new PhabricatorObjectQuery()) - ->setViewer($this->getActor()) - ->withPHIDs($nearby_phids) + // We require that objects you specify in "beforePHIDs" or "afterPHIDs" + // are real objects which exist and which you have permission to view. + // If you provide other objects, we remove them from the specification. + + if ($relative_phids) { + $objects = id(new PhabricatorObjectQuery()) + ->setViewer($actor) + ->withPHIDs($relative_phids) ->execute(); - $nearby_objects = mpull($nearby_objects, null, 'getPHID'); + $objects = mpull($objects, null, 'getPHID'); } else { - $nearby_objects = array(); + $objects = array(); + } + + foreach ($new as $key => $value) { + $value['afterPHIDs'] = $this->filterValidPHIDs( + $value['afterPHIDs'], + $objects); + $value['beforePHIDs'] = $this->filterValidPHIDs( + $value['beforePHIDs'], + $objects); + + $new[$key] = $value; } $column_phids = ipull($new, 'columnPHID'); if ($column_phids) { $columns = id(new PhabricatorProjectColumnQuery()) - ->setViewer($this->getActor()) + ->setViewer($actor) ->withPHIDs($column_phids) ->execute(); $columns = mpull($columns, null, 'getPHID'); @@ -487,10 +536,9 @@ final class ManiphestTransactionEditor $board_phids = mpull($columns, 'getProjectPHID'); $object_phid = $object->getPHID(); - $object_phids = $nearby_phids; - // Note that we may not have an object PHID if we're creating a new // object. + $object_phids = array(); if ($object_phid) { $object_phids[] = $object_phid; } @@ -517,49 +565,6 @@ final class ManiphestTransactionEditor $board_phid = $column->getProjectPHID(); - $nearby = array(); - - if (!empty($spec['beforePHID'])) { - $nearby['beforePHID'] = $spec['beforePHID']; - } - - if (!empty($spec['afterPHID'])) { - $nearby['afterPHID'] = $spec['afterPHID']; - } - - if (count($nearby) > 1) { - throw new Exception( - pht( - 'Column move transaction moves object to multiple positions. '. - 'Specify only "beforePHID" or "afterPHID", not both.')); - } - - foreach ($nearby as $where => $nearby_phid) { - if (empty($nearby_objects[$nearby_phid])) { - throw new Exception( - pht( - 'Column move transaction specifies object "%s" as "%s", but '. - 'there is no corresponding object with this PHID.', - $object_phid, - $where)); - } - - $nearby_columns = $layout_engine->getObjectColumns( - $board_phid, - $nearby_phid); - $nearby_columns = mpull($nearby_columns, null, 'getPHID'); - - if (empty($nearby_columns[$column_phid])) { - throw new Exception( - pht( - 'Column move transaction specifies object "%s" as "%s" in '. - 'column "%s", but this object is not in that column!', - $nearby_phid, - $where, - $column_phid)); - } - } - if ($object_phid) { $old_columns = $layout_engine->getObjectColumns( $board_phid, @@ -578,8 +583,8 @@ final class ManiphestTransactionEditor // We can just drop this column change if it has no effect. $from_map = array_fuse($spec['fromColumnPHIDs']); $already_here = isset($from_map[$column_phid]); - $is_reordering = (bool)$nearby; + $is_reordering = ($spec['afterPHIDs'] || $spec['beforePHIDs']); if ($already_here && !$is_reordering) { unset($new[$key]); } else { @@ -677,8 +682,9 @@ final class ManiphestTransactionEditor private function applyBoardMove($object, array $move) { $board_phid = $move['boardPHID']; $column_phid = $move['columnPHID']; - $before_phid = idx($move, 'beforePHID'); - $after_phid = idx($move, 'afterPHID'); + + $before_phids = $move['beforePHIDs']; + $after_phids = $move['afterPHIDs']; $object_phid = $object->getPHID(); @@ -730,24 +736,12 @@ final class ManiphestTransactionEditor $object_phid); } - if ($before_phid) { - $engine->queueAddPositionBefore( - $board_phid, - $column_phid, - $object_phid, - $before_phid); - } else if ($after_phid) { - $engine->queueAddPositionAfter( - $board_phid, - $column_phid, - $object_phid, - $after_phid); - } else { - $engine->queueAddPosition( - $board_phid, - $column_phid, - $object_phid); - } + $engine->queueAddPosition( + $board_phid, + $column_phid, + $object_phid, + $after_phids, + $before_phids); $engine->applyPositionUpdates(); } @@ -849,4 +843,16 @@ final class ManiphestTransactionEditor return $errors; } + private function filterValidPHIDs($phid_list, array $object_map) { + foreach ($phid_list as $key => $phid) { + if (isset($object_map[$phid])) { + continue; + } + + unset($phid_list[$key]); + } + + return array_values($phid_list); + } + } diff --git a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php index e50c83ab5a..186ac7dea4 100644 --- a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php +++ b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php @@ -1008,29 +1008,32 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $task2->getPHID(), $task1->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('Simple move'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); // Move the second task after the first task. $options = array( - 'afterPHID' => $task1->getPHID(), + 'afterPHIDs' => array($task1->getPHID()), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task1->getPHID(), $task2->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('With afterPHIDs'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); // Move the second task before the first task. $options = array( - 'beforePHID' => $task1->getPHID(), + 'beforePHIDs' => array($task1->getPHID()), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task2->getPHID(), $task1->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('With beforePHIDs'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); } public function testMilestoneMoves() { @@ -1333,7 +1336,8 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { array $expect, PhabricatorUser $viewer, PhabricatorProject $board, - PhabricatorProjectColumn $column) { + PhabricatorProjectColumn $column, + $label = null) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) @@ -1346,7 +1350,7 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $column->getPHID()); $object_phids = array_values($object_phids); - $this->assertEqual($expect, $object_phids); + $this->assertEqual($expect, $object_phids, $label); } private function addColumn( diff --git a/src/applications/project/controller/PhabricatorProjectMoveController.php b/src/applications/project/controller/PhabricatorProjectMoveController.php index 950b1e90cd..273a068c06 100644 --- a/src/applications/project/controller/PhabricatorProjectMoveController.php +++ b/src/applications/project/controller/PhabricatorProjectMoveController.php @@ -11,9 +11,20 @@ final class PhabricatorProjectMoveController $column_phid = $request->getStr('columnPHID'); $object_phid = $request->getStr('objectPHID'); + $after_phid = $request->getStr('afterPHID'); $before_phid = $request->getStr('beforePHID'); + $after_phids = array(); + if ($after_phid) { + $after_phids[] = $after_phid; + } + + $before_phids = array(); + if ($before_phid) { + $before_phids[] = $before_phid; + } + $order = $request->getStr('order'); if (!strlen($order)) { $order = PhabricatorProjectColumnNaturalOrder::ORDERKEY; @@ -89,9 +100,10 @@ final class PhabricatorProjectMoveController $order_params = array(); if ($after_phid) { - $order_params['afterPHID'] = $after_phid; - } else if ($before_phid) { - $order_params['beforePHID'] = $before_phid; + $order_params['afterPHIDs'] = $after_phids; + } + if ($before_phid) { + $order_params['beforePHIDs'] = $before_phids; } $xactions = array(); diff --git a/src/applications/project/engine/PhabricatorBoardLayoutEngine.php b/src/applications/project/engine/PhabricatorBoardLayoutEngine.php index dcbe2d4ee4..e614ec2f94 100644 --- a/src/applications/project/engine/PhabricatorBoardLayoutEngine.php +++ b/src/applications/project/engine/PhabricatorBoardLayoutEngine.php @@ -135,52 +135,12 @@ final class PhabricatorBoardLayoutEngine extends Phobject { return $this; } - public function queueAddPositionBefore( - $board_phid, - $column_phid, - $object_phid, - $before_phid) { - - return $this->queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - $before_phid, - true); - } - - public function queueAddPositionAfter( - $board_phid, - $column_phid, - $object_phid, - $after_phid) { - - return $this->queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - $after_phid, - false); - } - public function queueAddPosition( - $board_phid, - $column_phid, - $object_phid) { - return $this->queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - null, - true); - } - - private function queueAddPositionRelative( $board_phid, $column_phid, $object_phid, - $relative_phid, - $is_before) { + array $after_phids, + array $before_phids) { $board_layout = idx($this->boardLayout, $board_phid, array()); $positions = idx($board_layout, $column_phid, array()); @@ -196,54 +156,76 @@ final class PhabricatorBoardLayoutEngine extends Phobject { ->setObjectPHID($object_phid); } - $found = false; if (!$positions) { $object_position->setSequence(0); } else { - foreach ($positions as $position) { - if (!$found) { - if ($relative_phid === null) { - $is_match = true; - } else { - $position_phid = $position->getObjectPHID(); - $is_match = ($relative_phid == $position_phid); + // The user's view of the board may fall out of date, so they might + // try to drop a card under a different card which is no longer where + // they thought it was. + + // When this happens, we perform the move anyway, since this is almost + // certainly what users want when interacting with the UI. We'l try to + // fall back to another nearby card if the client provided us one. If + // we don't find any of the cards the client specified in the column, + // we'll just move the card to the default position. + + $search_phids = array(); + foreach ($after_phids as $after_phid) { + $search_phids[] = array($after_phid, false); + } + + foreach ($before_phids as $before_phid) { + $search_phids[] = array($before_phid, true); + } + + // This makes us fall back to the default position if we fail every + // candidate position. The default position counts as a "before" position + // because we want to put the new card at the top of the column. + $search_phids[] = array(null, true); + + $found = false; + foreach ($search_phids as $search_position) { + list($relative_phid, $is_before) = $search_position; + foreach ($positions as $position) { + if (!$found) { + if ($relative_phid === null) { + $is_match = true; + } else { + $position_phid = $position->getObjectPHID(); + $is_match = ($relative_phid === $position_phid); + } + + if ($is_match) { + $found = true; + + $sequence = $position->getSequence(); + + if (!$is_before) { + $sequence++; + } + + $object_position->setSequence($sequence++); + + if (!$is_before) { + // If we're inserting after this position, continue the loop so + // we don't update it. + continue; + } + } } - if ($is_match) { - $found = true; - - $sequence = $position->getSequence(); - - if (!$is_before) { - $sequence++; - } - - $object_position->setSequence($sequence++); - - if (!$is_before) { - // If we're inserting after this position, continue the loop so - // we don't update it. - continue; - } + if ($found) { + $position->setSequence($sequence++); + $this->addQueue[] = $position; } } if ($found) { - $position->setSequence($sequence++); - $this->addQueue[] = $position; + break; } } } - if ($relative_phid && !$found) { - throw new Exception( - pht( - 'Unable to find object "%s" in column "%s" on board "%s".', - $relative_phid, - $column_phid, - $board_phid)); - } - $this->addQueue[] = $object_position; $positions[$object_phid] = $object_position; diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index 0cd8abb7d2..7f1383ac64 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -495,7 +495,7 @@ JX.install('WorkboardBoard', { order: this.getOrder() }; - var context = this._getDropContext(after_node); + var context = this._getDropContext(after_node, item); if (context.afterPHID) { data.afterPHID = context.afterPHID; From 71c89bd0578d8380d7718c4e43bfa80eb5af3bb8 Mon Sep 17 00:00:00 2001 From: epriestley Date: Mon, 25 Mar 2019 09:37:02 -0700 Subject: [PATCH 31/52] Pass all adjacent card PHIDs from the client to the server when moving a card Summary: Depends on D20321. Fixes T12175. Ref T13074. Now that before/after PHIDs are suggestions, we can give the server a more complete view of what the client is trying to do so we're more likely to get a good outcome if the client view is out of date. Instead of passing only the one directly adjacent card PHID, pass all the card PHIDs that the client thinks are in the same group. (For gigantic columns with tens of thousands of tasks this might need some tweaking -- like, slice both lists down to 10 items -- but we can cross that bridge when we come to it.) Test Plan: - Dragged some cards around to top/bottom/middle positions, saw good positioning in all cases. - In two windows, dragged stuff around on the same board. At least at first glance, conflicting simultaneous edits seemed to do reasonable things. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13074, T12175 Differential Revision: https://secure.phabricator.com/D20322 --- resources/celerity/map.php | 28 +++++------ .../PhabricatorProjectMoveController.php | 25 +++------- .../js/application/projects/WorkboardBoard.js | 47 ++++++++----------- 3 files changed, 40 insertions(+), 60 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 972a5c2b56..12c34cd332 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -409,7 +409,7 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '106d870f', + 'rsrc/js/application/projects/WorkboardBoard.js' => 'c02a5497', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', @@ -737,7 +737,7 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '106d870f', + 'javelin-workboard-board' => 'c02a5497', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', 'javelin-workboard-column' => 'c3d24e63', @@ -1015,18 +1015,6 @@ return array( 'javelin-workflow', 'phuix-icon-view', ), - '106d870f' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), '111bfd2d' => array( 'javelin-install', ), @@ -1940,6 +1928,18 @@ return array( 'bde53589' => array( 'phui-inline-comment-view-css', ), + 'c02a5497' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), 'c03f2fb4' => array( 'javelin-install', ), diff --git a/src/applications/project/controller/PhabricatorProjectMoveController.php b/src/applications/project/controller/PhabricatorProjectMoveController.php index 273a068c06..1fd8b3c677 100644 --- a/src/applications/project/controller/PhabricatorProjectMoveController.php +++ b/src/applications/project/controller/PhabricatorProjectMoveController.php @@ -12,18 +12,8 @@ final class PhabricatorProjectMoveController $column_phid = $request->getStr('columnPHID'); $object_phid = $request->getStr('objectPHID'); - $after_phid = $request->getStr('afterPHID'); - $before_phid = $request->getStr('beforePHID'); - - $after_phids = array(); - if ($after_phid) { - $after_phids[] = $after_phid; - } - - $before_phids = array(); - if ($before_phid) { - $before_phids[] = $before_phid; - } + $after_phids = $request->getStrList('afterPHIDs'); + $before_phids = $request->getStrList('beforePHIDs'); $order = $request->getStr('order'); if (!strlen($order)) { @@ -98,13 +88,10 @@ final class PhabricatorProjectMoveController ->setObjectPHIDs(array($object_phid)) ->executeLayout(); - $order_params = array(); - if ($after_phid) { - $order_params['afterPHIDs'] = $after_phids; - } - if ($before_phid) { - $order_params['beforePHIDs'] = $before_phids; - } + $order_params = array( + 'afterPHIDs' => $after_phids, + 'beforePHIDs' => $before_phids, + ); $xactions = array(); $xactions[] = id(new ManiphestTransaction()) diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index 7f1383ac64..74c0bdf23e 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -409,8 +409,8 @@ JX.install('WorkboardBoard', { _getDropContext: function(after_node, item) { var header_key; - var before_phid; - var after_phid; + var after_phids = []; + var before_phids = []; // We're going to send an "afterPHID" and a "beforePHID" if the card // was dropped immediately adjacent to another card. If a card was @@ -424,19 +424,16 @@ JX.install('WorkboardBoard', { var after_card = after_node; while (after_card) { after_data = JX.Stratcom.getData(after_card); - if (after_data.objectPHID) { - break; - } + if (after_data.headerKey) { break; } - after_card = after_card.previousSibling; - } - if (after_data) { if (after_data.objectPHID) { - after_phid = after_data.objectPHID; + after_phids.push(after_data.objectPHID); } + + after_card = after_card.previousSibling; } if (item) { @@ -444,19 +441,16 @@ JX.install('WorkboardBoard', { var before_card = item.nextSibling; while (before_card) { before_data = JX.Stratcom.getData(before_card); - if (before_data.objectPHID) { - break; - } + if (before_data.headerKey) { break; } - before_card = before_card.nextSibling; - } - if (before_data) { if (before_data.objectPHID) { - before_phid = before_data.objectPHID; + before_phids.push(before_data.objectPHID); } + + before_card = before_card.nextSibling; } } @@ -476,8 +470,8 @@ JX.install('WorkboardBoard', { return { headerKey: header_key, - afterPHID: after_phid, - beforePHID: before_phid + afterPHIDs: after_phids, + beforePHIDs: before_phids }; }, @@ -496,14 +490,8 @@ JX.install('WorkboardBoard', { }; var context = this._getDropContext(after_node, item); - - if (context.afterPHID) { - data.afterPHID = context.afterPHID; - } - - if (context.beforePHID) { - data.beforePHID = context.beforePHID; - } + data.afterPHIDs = context.afterPHIDs.join(','); + data.beforePHIDs = context.beforePHIDs.join(','); if (context.headerKey) { var properties = this.getHeaderTemplate(context.headerKey) @@ -530,13 +518,18 @@ JX.install('WorkboardBoard', { src_phid, dst_phid); + var after_phid = null; + if (data.afterPHIDs.length) { + after_phid = data.afterPHIDs[0]; + } + var onupdate = JX.bind( this, this._oncardupdate, list, src_phid, dst_phid, - data.afterPHID); + after_phid); new JX.Workflow(this.getController().getMoveURI(), data) .setHandler(onupdate) From d347b102a1ede10cf20324e7d70cd4c6615907c6 Mon Sep 17 00:00:00 2001 From: Austin McKinley Date: Tue, 26 Mar 2019 11:30:59 -0700 Subject: [PATCH 32/52] Add workboard trigger rule for changing task priority Summary: This is a copy/paste/find-and-replace-all of the status rule added by D20288. Test Plan: Made some triggers, moved some tasks, edited some triggers. Grepped for the word "status" in the new file. Reviewers: epriestley Reviewed By: epriestley Subscribers: Korvin Differential Revision: https://secure.phabricator.com/D20325 --- src/__phutil_library_map__.php | 2 + ...torProjectTriggerManiphestPriorityRule.php | 94 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index ba02f45406..33f2cf4d33 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4180,6 +4180,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerEditor' => 'applications/project/editor/PhabricatorProjectTriggerEditor.php', 'PhabricatorProjectTriggerInvalidRule' => 'applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php', 'PhabricatorProjectTriggerListController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerListController.php', + 'PhabricatorProjectTriggerManiphestPriorityRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php', 'PhabricatorProjectTriggerManiphestStatusRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php', 'PhabricatorProjectTriggerNameTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php', 'PhabricatorProjectTriggerPHIDType' => 'applications/project/phid/PhabricatorProjectTriggerPHIDType.php', @@ -10318,6 +10319,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTriggerEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectTriggerInvalidRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerListController' => 'PhabricatorProjectTriggerController', + 'PhabricatorProjectTriggerManiphestPriorityRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerManiphestStatusRule' => 'PhabricatorProjectTriggerRule', 'PhabricatorProjectTriggerNameTransaction' => 'PhabricatorProjectTriggerTransactionType', 'PhabricatorProjectTriggerPHIDType' => 'PhabricatorPHIDType', diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php new file mode 100644 index 0000000000..98a03a1393 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php @@ -0,0 +1,94 @@ +newTransaction() + ->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) + ->setNewValue($value), + ); + } + + protected function newDropEffects($value) { + $priority_name = ManiphestTaskPriority::getTaskPriorityName($value); + $priority_icon = ManiphestTaskPriority::getTaskPriorityIcon($value); + $priority_color = ManiphestTaskPriority::getTaskPriorityColor($value); + + $content = pht( + 'Change priority to %s.', + phutil_tag('strong', array(), $priority_name)); + + return array( + $this->newEffect() + ->setIcon($priority_icon) + ->setColor($priority_color) + ->addCondition('priority', '!=', $value) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(ManiphestTaskPriority::getTaskPriorityMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = ManiphestTaskPriority::getTaskPriorityMap(); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Change Priority'); + } + + public function getRuleViewDescription($value) { + $priority_name = ManiphestTaskPriority::getTaskPriorityName($value); + + return pht( + 'Change task priority to %s.', + phutil_tag('strong', array(), $priority_name)); + } + + public function getRuleViewIcon($value) { + $priority_icon = ManiphestTaskPriority::getTaskPriorityIcon($value); + $priority_color = ManiphestTaskPriority::getTaskPriorityColor($value); + + return id(new PHUIIconView()) + ->setIcon($priority_icon, $priority_color); + } + + +} From f6658bf391d3cd654cf25f47bd547bfc17f9457a Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 26 Mar 2019 11:44:55 -0700 Subject: [PATCH 33/52] When changing the trigger type in the trigger editor, properly redraw the control Summary: Ref T13269. I refactored this late in the game to organize things better and add table cells around stuff, and accidentally broke the relationship between the "Rule Type" selector and the value selector. Test Plan: Switched rule type selector from "Change Status" to "Play Sound", saw secondary control update properly. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13269 Differential Revision: https://secure.phabricator.com/D20326 --- resources/celerity/map.php | 4 ++-- webroot/rsrc/js/application/trigger/TriggerRule.js | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 12c34cd332..7f5c8ff45e 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -433,7 +433,7 @@ return array( 'rsrc/js/application/transactions/behavior-show-older-transactions.js' => '600f440c', 'rsrc/js/application/transactions/behavior-transaction-comment-form.js' => '2bdadf1a', 'rsrc/js/application/transactions/behavior-transaction-list.js' => '9cec214e', - 'rsrc/js/application/trigger/TriggerRule.js' => 'e4a816a4', + 'rsrc/js/application/trigger/TriggerRule.js' => '1c60c3fc', 'rsrc/js/application/trigger/TriggerRuleControl.js' => '5faf27b9', 'rsrc/js/application/trigger/TriggerRuleEditor.js' => 'b49fd60c', 'rsrc/js/application/trigger/TriggerRuleType.js' => '4feea7d3', @@ -894,7 +894,7 @@ return array( 'syntax-default-css' => '055fc231', 'syntax-highlighting-css' => '4234f572', 'tokens-css' => 'ce5a50bd', - 'trigger-rule' => 'e4a816a4', + 'trigger-rule' => '1c60c3fc', 'trigger-rule-control' => '5faf27b9', 'trigger-rule-editor' => 'b49fd60c', 'trigger-rule-type' => '4feea7d3', diff --git a/webroot/rsrc/js/application/trigger/TriggerRule.js b/webroot/rsrc/js/application/trigger/TriggerRule.js index 69feeade18..cf117e24d9 100644 --- a/webroot/rsrc/js/application/trigger/TriggerRule.js +++ b/webroot/rsrc/js/application/trigger/TriggerRule.js @@ -84,8 +84,8 @@ JX.install('TriggerRule', { control.value = this.getType(); - var on_change = JX.bind(this, this._onTypeChange); - JX.DOM.listen(control, 'onchange', null, on_change); + var on_change = JX.bind(this, this._onTypeChange, control); + JX.DOM.listen(control, 'change', null, on_change); var attributes = { className: 'type-cell' @@ -97,10 +97,8 @@ JX.install('TriggerRule', { return this._typeCell; }, - _onTypeChange: function() { - var control = this._getTypeCell(); + _onTypeChange: function(control) { this.setType(control.value); - this._rebuildValueControl(); }, From b328af0a1b16bf169d53d607624ec429f7764740 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 26 Mar 2019 14:23:00 -0700 Subject: [PATCH 34/52] Raise a more tailored exception if transform/thumbnail support is missing for cover images Summary: If "GD" doesn't support a particular image type, applying a cover image currently goes through but no-ops. Fail it earlier in the process with a more specific error. Test Plan: Without PNG support locally, dropped a PNG onto a card on a workboard. Got a more useful error. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20328 --- .../ManiphestTaskCoverImageTransaction.php | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php b/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php index eb29c711f8..5e6f63c5c4 100644 --- a/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php +++ b/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php @@ -82,17 +82,35 @@ final class ManiphestTaskCoverImageTransaction if (!$file) { $errors[] = $this->newInvalidError( - pht('"%s" is not a valid file PHID.', - $file_phid)); - } else { - if (!$file->isViewableImage()) { - $mime_type = $file->getMimeType(); - $errors[] = $this->newInvalidError( - pht('File mime type of "%s" is not a valid viewable image.', - $mime_type)); - } + pht( + 'File PHID ("%s") is invalid, or you do not have permission '. + 'to view it.', + $file_phid), + $xaction); + continue; } + if (!$file->isViewableImage()) { + $errors[] = $this->newInvalidError( + pht( + 'File ("%s", with MIME type "%s") is not a viewable image file.', + $file_phid, + $file->getMimeType()), + $xaction); + continue; + } + + if (!$file->isTransformableImage()) { + $errors[] = $this->newInvalidError( + pht( + 'File ("%s", with MIME type "%s") can not be transformed into '. + 'a thumbnail. You may be missing support for this file type in '. + 'the "GD" extension.', + $file_phid, + $file->getMimeType()), + $xaction); + continue; + } } return $errors; From 4485482fd4cc7ad8fe100c694446e70a550da1d4 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 26 Mar 2019 15:27:05 -0700 Subject: [PATCH 35/52] Fix task hovercards showing a "Not Editable" state Summary: Ref T13269. These cards really have three states: - Editable: shows a pencil icon edit button. - You Do Not Have Permission To Edit This: shows a "no editing" icon in red. - Hovecard: shouldn't show anything. However, the "hovercard" and "no permission" states are currently the same state, so when I made the "no permission" state more obvious that made the hovercard go all weird. Make these states explicitly separate states. Test Plan: Looked at a... - Editable card on workboard: edit state. - No permission card on workboard: no permission state. - Any hovercard: "not editable, this is a hovercard" state. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13269 Differential Revision: https://secure.phabricator.com/D20330 --- .../ManiphestHovercardEngineExtension.php | 3 +- .../PhabricatorBoardRenderingEngine.php | 1 + .../project/view/ProjectBoardTaskCard.php | 46 ++++++++++++------- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php b/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php index 2b2ef3c93d..7474f9cfaf 100644 --- a/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php +++ b/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php @@ -47,8 +47,7 @@ final class ManiphestHovercardEngineExtension $card = id(new ProjectBoardTaskCard()) ->setViewer($viewer) - ->setTask($task) - ->setCanEdit(false); + ->setTask($task); $owner_phid = $task->getOwnerPHID(); if ($owner_phid) { diff --git a/src/applications/project/engine/PhabricatorBoardRenderingEngine.php b/src/applications/project/engine/PhabricatorBoardRenderingEngine.php index d76497bc21..f5a81eb9b0 100644 --- a/src/applications/project/engine/PhabricatorBoardRenderingEngine.php +++ b/src/applications/project/engine/PhabricatorBoardRenderingEngine.php @@ -56,6 +56,7 @@ final class PhabricatorBoardRenderingEngine extends Phobject { $card = id(new ProjectBoardTaskCard()) ->setViewer($viewer) ->setTask($object) + ->setShowEditControls(true) ->setCanEdit($this->getCanEdit($phid)); $owner_phid = $object->getOwnerPHID(); diff --git a/src/applications/project/view/ProjectBoardTaskCard.php b/src/applications/project/view/ProjectBoardTaskCard.php index bb1c8ca8c5..d102ac1b11 100644 --- a/src/applications/project/view/ProjectBoardTaskCard.php +++ b/src/applications/project/view/ProjectBoardTaskCard.php @@ -6,6 +6,7 @@ final class ProjectBoardTaskCard extends Phobject { private $projectHandles; private $task; private $owner; + private $showEditControls; private $canEdit; private $coverImageFile; private $hideArchivedProjects; @@ -70,6 +71,15 @@ final class ProjectBoardTaskCard extends Phobject { return $this->canEdit; } + public function setShowEditControls($show_edit_controls) { + $this->showEditControls = $show_edit_controls; + return $this; + } + + public function getShowEditControls() { + return $this->showEditControls; + } + public function getItem() { $task = $this->getTask(); $owner = $this->getOwner(); @@ -89,24 +99,26 @@ final class ProjectBoardTaskCard extends Phobject { ->setDisabled($task->isClosed()) ->setBarColor($bar_color); - if ($can_edit) { - $card - ->addSigil('draggable-card') - ->addClass('draggable-card'); - $edit_icon = 'fa-pencil'; - } else { - $card - ->addClass('not-editable') - ->addClass('undraggable-card'); - $edit_icon = 'fa-lock red'; - } + if ($this->getShowEditControls()) { + if ($can_edit) { + $card + ->addSigil('draggable-card') + ->addClass('draggable-card'); + $edit_icon = 'fa-pencil'; + } else { + $card + ->addClass('not-editable') + ->addClass('undraggable-card'); + $edit_icon = 'fa-lock red'; + } - $card->addAction( - id(new PHUIListItemView()) - ->setName(pht('Edit')) - ->setIcon($edit_icon) - ->addSigil('edit-project-card') - ->setHref('/maniphest/task/edit/'.$task->getID().'/')); + $card->addAction( + id(new PHUIListItemView()) + ->setName(pht('Edit')) + ->setIcon($edit_icon) + ->addSigil('edit-project-card') + ->setHref('/maniphest/task/edit/'.$task->getID().'/')); + } if ($owner) { $card->addHandleIcon($owner, $owner->getName()); From ee54e71ba9aaa917148fe4df052b55f180285777 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 26 Mar 2019 16:30:32 -0700 Subject: [PATCH 36/52] On workboards, link ancestor project breadcrumbs to their workboards Summary: Ref T13269. Currently, if you're on a milestone workboard like this: > Projects > Parent > Milestone > Workboard The "Parent" link goes to the parent profile. More often, I want it to go to the parent workboard. Try doing that? This is kind of one-off but I suspect it's a better rule. Also, consolidate one billion manual constructions of "/board/" URIs. Test Plan: Viewed a milestone workboard, clicked the parent link, ended up on the parent workboard. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13269 Differential Revision: https://secure.phabricator.com/D20331 --- .../editor/ManiphestTransactionEditor.php | 3 +- ...icatorProjectBoardBackgroundController.php | 2 +- ...habricatorProjectBoardManageController.php | 2 +- .../PhabricatorProjectBoardViewController.php | 2 +- ...abricatorProjectColumnDetailController.php | 2 +- ...PhabricatorProjectColumnEditController.php | 3 +- ...PhabricatorProjectColumnHideController.php | 2 +- ...orProjectColumnRemoveTriggerController.php | 2 +- .../PhabricatorProjectController.php | 30 ++++++++++++++++--- ...habricatorProjectTriggerEditController.php | 4 +-- ...habricatorProjectTriggerViewController.php | 2 +- .../PhabricatorProjectsCurtainExtension.php | 2 +- .../PhabricatorProjectUIEventListener.php | 2 +- ...ricatorProjectWorkboardProfileMenuItem.php | 2 +- .../phid/PhabricatorProjectColumnPHIDType.php | 2 +- .../project/storage/PhabricatorProject.php | 4 +++ .../storage/PhabricatorProjectColumn.php | 6 ++-- 17 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 5198f44572..fd5bfe0cdc 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -243,8 +243,7 @@ final class ManiphestTransactionEditor foreach ($projects as $project) { $body->addLinkSection( pht('WORKBOARD'), - PhabricatorEnv::getProductionURI( - '/project/board/'.$project->getID().'/')); + PhabricatorEnv::getProductionURI($project->getWorkboardURI())); } } diff --git a/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php b/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php index b229f59ecb..c70c211398 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php @@ -55,7 +55,7 @@ final class PhabricatorProjectBoardBackgroundController $nav = $this->getProfileMenu(); $crumbs = id($this->buildApplicationCrumbs()) - ->addTextCrumb(pht('Workboard'), "/project/board/{$board_id}/") + ->addTextCrumb(pht('Workboard'), $board->getWorkboardURI()) ->addTextCrumb(pht('Manage'), $manage_uri) ->addTextCrumb(pht('Background Color')); diff --git a/src/applications/project/controller/PhabricatorProjectBoardManageController.php b/src/applications/project/controller/PhabricatorProjectBoardManageController.php index 5c71dcfb61..21daf2e654 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardManageController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardManageController.php @@ -34,7 +34,7 @@ final class PhabricatorProjectBoardManageController $curtain = $this->buildCurtainView($board); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Workboard'), "/project/board/{$board_id}/"); + $crumbs->addTextCrumb(pht('Workboard'), $board->getWorkboardURI()); $crumbs->addTextCrumb(pht('Manage')); $crumbs->setBorder(true); diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index e8a47d362a..775ff1b61a 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -726,7 +726,7 @@ final class PhabricatorProjectBoardViewController ->setType(PHUIListItemView::TYPE_DIVIDER); $fullscreen = $this->buildFullscreenMenu(); - $crumbs = $this->buildApplicationCrumbs(); + $crumbs = $this->newWorkboardCrumbs(); $crumbs->addTextCrumb(pht('Workboard')); $crumbs->setBorder(true); diff --git a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php index 24efec5ebb..781461a812 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php @@ -47,7 +47,7 @@ final class PhabricatorProjectColumnDetailController $properties = $this->buildPropertyView($column); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Workboard'), "/project/board/{$project_id}/"); + $crumbs->addTextCrumb(pht('Workboard'), $project->getWorkboardURI()); $crumbs->addTextCrumb(pht('Column: %s', $title)); $crumbs->setBorder(true); diff --git a/src/applications/project/controller/PhabricatorProjectColumnEditController.php b/src/applications/project/controller/PhabricatorProjectColumnEditController.php index 567b923407..9ddb2b7d8a 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnEditController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnEditController.php @@ -50,8 +50,7 @@ final class PhabricatorProjectColumnEditController $v_name = $column->getName(); $validation_exception = null; - $base_uri = '/board/'.$project_id.'/'; - $view_uri = $this->getApplicationURI($base_uri); + $view_uri = $project->getWorkboardURI(); if ($request->isFormPost()) { $v_name = $request->getStr('name'); diff --git a/src/applications/project/controller/PhabricatorProjectColumnHideController.php b/src/applications/project/controller/PhabricatorProjectColumnHideController.php index 61811af5c3..254beab78c 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnHideController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnHideController.php @@ -38,7 +38,7 @@ final class PhabricatorProjectColumnHideController $column_phid = $column->getPHID(); - $view_uri = $this->getApplicationURI('/board/'.$project_id.'/'); + $view_uri = $project->getWorkboardURI(); $view_uri = new PhutilURI($view_uri); foreach ($request->getPassthroughRequestData() as $key => $value) { $view_uri->replaceQueryParam($key, $value); diff --git a/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php index 5802449dcb..9bb92e5a3a 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php @@ -20,7 +20,7 @@ final class PhabricatorProjectColumnRemoveTriggerController return new Aphront404Response(); } - $done_uri = $column->getBoardURI(); + $done_uri = $column->getWorkboardURI(); if (!$column->getTriggerPHID()) { return $this->newDialog() diff --git a/src/applications/project/controller/PhabricatorProjectController.php b/src/applications/project/controller/PhabricatorProjectController.php index c28ace305c..63494bf442 100644 --- a/src/applications/project/controller/PhabricatorProjectController.php +++ b/src/applications/project/controller/PhabricatorProjectController.php @@ -109,6 +109,14 @@ abstract class PhabricatorProjectController extends PhabricatorController { } protected function buildApplicationCrumbs() { + return $this->newApplicationCrumbs('profile'); + } + + protected function newWorkboardCrumbs() { + return $this->newApplicationCrumbs('workboard'); + } + + private function newApplicationCrumbs($mode) { $crumbs = parent::buildApplicationCrumbs(); $project = $this->getProject(); @@ -117,10 +125,24 @@ abstract class PhabricatorProjectController extends PhabricatorController { $ancestors = array_reverse($ancestors); $ancestors[] = $project; foreach ($ancestors as $ancestor) { - $crumbs->addTextCrumb( - $ancestor->getName(), - $ancestor->getProfileURI() - ); + if ($ancestor->getPHID() === $project->getPHID()) { + // Link the current project's crumb to its profile no matter what, + // since we're already on the right context page for it and linking + // to the current page isn't helpful. + $crumb_uri = $ancestor->getProfileURI(); + } else { + switch ($mode) { + case 'workboard': + $crumb_uri = $ancestor->getWorkboardURI(); + break; + case 'profile': + default: + $crumb_uri = $ancestor->getProfileURI(); + break; + } + } + + $crumbs->addTextCrumb($ancestor->getName(), $crumb_uri); } } diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php index 7189df70ec..df362efb61 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php @@ -39,7 +39,7 @@ final class PhabricatorProjectTriggerEditController if (!$column) { return new Aphront404Response(); } - $board_uri = $column->getBoardURI(); + $board_uri = $column->getWorkboardURI(); } else { $column = null; $board_uri = null; @@ -122,7 +122,7 @@ final class PhabricatorProjectTriggerEditController $column_editor->applyTransactions($column, $column_xactions); - $next_uri = $column->getBoardURI(); + $next_uri = $column->getWorkboardURI(); } return id(new AphrontRedirectResponse())->setURI($next_uri); diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php index e18419fedb..d148c0a421 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php @@ -111,7 +111,7 @@ final class PhabricatorProjectTriggerViewController $column_name = phutil_tag( 'a', array( - 'href' => $column->getBoardURI(), + 'href' => $column->getWorkboardURI(), ), $column->getDisplayName()); } else { diff --git a/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php b/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php index c69e130275..7251323415 100644 --- a/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php +++ b/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php @@ -55,7 +55,7 @@ final class PhabricatorProjectsCurtainExtension $column_link = phutil_tag( 'a', array( - 'href' => "/project/board/{$project_id}/", + 'href' => $column->getWorkboardURI(), 'class' => 'maniphest-board-link', ), $column_name); diff --git a/src/applications/project/events/PhabricatorProjectUIEventListener.php b/src/applications/project/events/PhabricatorProjectUIEventListener.php index 104084bbf7..25d1ba9f74 100644 --- a/src/applications/project/events/PhabricatorProjectUIEventListener.php +++ b/src/applications/project/events/PhabricatorProjectUIEventListener.php @@ -81,7 +81,7 @@ final class PhabricatorProjectUIEventListener $column_link = phutil_tag( 'a', array( - 'href' => "/project/board/{$project_id}/", + 'href' => $column->getWorkboardURI(), 'class' => 'maniphest-board-link', ), $column_name); diff --git a/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php index 80ec0d835a..38b9632d93 100644 --- a/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php @@ -57,7 +57,7 @@ final class PhabricatorProjectWorkboardProfileMenuItem $project = $config->getProfileObject(); $id = $project->getID(); - $href = "/project/board/{$id}/"; + $href = $project->getWorkboardURI(); $name = $this->getDisplayName($config); $item = $this->newItem() diff --git a/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php b/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php index 07c7f7a0ee..c58bb44671 100644 --- a/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php +++ b/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php @@ -37,7 +37,7 @@ final class PhabricatorProjectColumnPHIDType extends PhabricatorPHIDType { $column = $objects[$phid]; $handle->setName($column->getDisplayName()); - $handle->setURI('/project/board/'.$column->getProject()->getID().'/'); + $handle->setURI($column->getWorkboardURI()); if ($column->isHidden()) { $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); diff --git a/src/applications/project/storage/PhabricatorProject.php b/src/applications/project/storage/PhabricatorProject.php index 5182a941bf..67ab05f5fd 100644 --- a/src/applications/project/storage/PhabricatorProject.php +++ b/src/applications/project/storage/PhabricatorProject.php @@ -392,6 +392,10 @@ final class PhabricatorProject extends PhabricatorProjectDAO return "/project/profile/{$id}/"; } + public function getWorkboardURI() { + return urisprintf('/project/board/%d/', $this->getID()); + } + public function save() { if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); diff --git a/src/applications/project/storage/PhabricatorProjectColumn.php b/src/applications/project/storage/PhabricatorProjectColumn.php index 731c2a15fb..49d7f28a9f 100644 --- a/src/applications/project/storage/PhabricatorProjectColumn.php +++ b/src/applications/project/storage/PhabricatorProjectColumn.php @@ -212,10 +212,8 @@ final class PhabricatorProjectColumn return true; } - public function getBoardURI() { - return urisprintf( - '/project/board/%d/', - $this->getProject()->getID()); + public function getWorkboardURI() { + return $this->getProject()->getWorkboardURI(); } public function getDropEffects() { From 3e1ffda85db3fa0abab00f7c198f5cc9449c8e7b Mon Sep 17 00:00:00 2001 From: epriestley Date: Wed, 27 Mar 2019 07:05:58 -0700 Subject: [PATCH 37/52] Give workboard column header actions a more clickable appearance Summary: Ref T13269. Make it visually more clear that the "Trigger" and "New Task / Edit / Bulk" dropdown menu items are buttons, not status icons or indicators of some kind. Test Plan: {F6313872} Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13269 Differential Revision: https://secure.phabricator.com/D20332 --- resources/celerity/map.php | 10 +++++----- .../rsrc/css/phui/workboards/phui-workpanel.css | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 7f5c8ff45e..c1a77bca11 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -179,7 +179,7 @@ return array( 'rsrc/css/phui/workboards/phui-workboard-color.css' => 'e86de308', 'rsrc/css/phui/workboards/phui-workboard.css' => '74fc9d98', 'rsrc/css/phui/workboards/phui-workcard.css' => '9e9eb0df', - 'rsrc/css/phui/workboards/phui-workpanel.css' => 'f43b8c7f', + 'rsrc/css/phui/workboards/phui-workpanel.css' => '3ae89b20', 'rsrc/css/sprite-login.css' => '18b368a6', 'rsrc/css/sprite-tokens.css' => 'f1896dc5', 'rsrc/css/syntax/syntax-default.css' => '055fc231', @@ -869,7 +869,7 @@ return array( 'phui-workboard-color-css' => 'e86de308', 'phui-workboard-view-css' => '74fc9d98', 'phui-workcard-view-css' => '9e9eb0df', - 'phui-workpanel-view-css' => 'f43b8c7f', + 'phui-workpanel-view-css' => '3ae89b20', 'phuix-action-list-view' => 'c68f183f', 'phuix-action-view' => 'aaa08f3b', 'phuix-autocomplete' => '8f139ef0', @@ -1223,6 +1223,9 @@ return array( 'trigger-rule', 'trigger-rule-type', ), + '3ae89b20' => array( + 'phui-workcard-view-css', + ), '3b4899b0' => array( 'javelin-behavior', 'phabricator-prefab', @@ -2141,9 +2144,6 @@ return array( 'phabricator-darklog', 'phabricator-darkmessage', ), - 'f43b8c7f' => array( - 'phui-workcard-view-css', - ), 'f51e9c17' => array( 'javelin-behavior', 'javelin-stratcom', diff --git a/webroot/rsrc/css/phui/workboards/phui-workpanel.css b/webroot/rsrc/css/phui/workboards/phui-workpanel.css index 5c0a62282b..5ee54f2deb 100644 --- a/webroot/rsrc/css/phui/workboards/phui-workpanel.css +++ b/webroot/rsrc/css/phui/workboards/phui-workpanel.css @@ -237,3 +237,18 @@ opacity: 0.25; } } + +.phui-workpanel-view .phui-header-action-item a.phui-icon-view { + width: 24px; + height: 24px; + line-height: 24px; + text-align: center; + border-radius: 3px; + box-shadow: inset -1px -1px 2px rgba(0, 0, 0, 0.05); + border: 1px solid {$lightgreyborder}; + background: {$lightgreybackground}; +} + +.phui-workpanel-view .phui-header-action-item .phui-tag-view { + line-height: 24px; +} From a68b6cfe6541faf75ba80f0a2d15d0f7f76fa071 Mon Sep 17 00:00:00 2001 From: epriestley Date: Wed, 27 Mar 2019 08:07:32 -0700 Subject: [PATCH 38/52] Use real Dashboard Panels to render the default hard-coded homepage, not hacky fake panels Summary: Ref T13272. Currently, the hard-coded default homepage looks like a dashboard but is actually rendered completely manually. This means that various panel rendering improvements we'd like to make (including better "Show More" behavior and better handling of overheated queries) won't work on the home page naturally: we'd have to make these changes twice, once for dashboards and once for the home page. Instead, build the home page out of real panels. This turns out to be significantly simpler (I think the backend part of panels/dashboards is mostly on solid footing, the frontend just needs some work). Test Plan: Loaded the default home page, saw a thing which looked the same as the old thing. Changes I know about / expect: - The headers for these panels are no longer linked, but they weren't colorized before so the links were hard to find. I plan to improve panel behavior for "find/more" in a followup. - I've removed the "follow us on twitter" default NUX if feed is empty, since this seems like unnecessary incidental complexity. - (Internal exception behavior should be better, now.) Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13272 Differential Revision: https://secure.phabricator.com/D20333 --- src/applications/home/view/PHUIHomeView.php | 163 ++++++-------------- 1 file changed, 45 insertions(+), 118 deletions(-) diff --git a/src/applications/home/view/PHUIHomeView.php b/src/applications/home/view/PHUIHomeView.php index d6c3794854..45750f5a93 100644 --- a/src/applications/home/view/PHUIHomeView.php +++ b/src/applications/home/view/PHUIHomeView.php @@ -77,149 +77,76 @@ final class PHUIHomeView return $view; } - private function buildHomepagePanel($title, $href, $view) { - $title = phutil_tag( - 'a', - array( - 'href' => $href, - ), - $title); - - $icon = id(new PHUIIconView()) - ->setIcon('fa-search') - ->setHref($href); - - $header = id(new PHUIHeaderView()) - ->setHeader($title) - ->addActionItem($icon); - - $box = id(new PHUIObjectBoxView()) - ->setHeader($header); - - if ($view->getObjectList()) { - $box->setObjectList($view->getObjectList()); - } - if ($view->getContent()) { - $box->appendChild($view->getContent()); - } - - return $box; - } - private function buildRevisionPanel() { $viewer = $this->getViewer(); if (!$viewer->isLoggedIn()) { return null; } - $engine = new DifferentialRevisionSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('active'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(15); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); + $panel = $this->newQueryPanel() + ->setName(pht('Active Revisions')) + ->setProperty('class', 'DifferentialRevisionSearchEngine') + ->setProperty('key', 'active'); - $title = pht('Active Revisions'); - $href = '/differential/query/active/'; - - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } private function buildTasksPanel() { $viewer = $this->getViewer(); - $query = 'assigned'; - $title = pht('Assigned Tasks'); - $href = '/maniphest/query/assigned/'; - if (!$viewer->isLoggedIn()) { + if ($viewer->isLoggedIn()) { + $name = pht('Assigned Tasks'); + $query = 'assigned'; + } else { + $name = pht('Open Tasks'); $query = 'open'; - $title = pht('Open Tasks'); - $href = '/maniphest/query/open/'; } - $engine = new ManiphestTaskSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin($query); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(15); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); + $panel = $this->newQueryPanel() + ->setName($name) + ->setProperty('class', 'ManiphestTaskSearchEngine') + ->setProperty('key', $query) + ->setProperty('limit', 15); - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } public function buildFeedPanel() { - $viewer = $this->getViewer(); + $panel = $this->newQueryPanel() + ->setName(pht('Recent Activity')) + ->setProperty('class', 'PhabricatorFeedSearchEngine') + ->setProperty('key', 'all') + ->setProperty('limit', 40); - $engine = new PhabricatorFeedSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('all'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(40); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); - // Low tech NUX. - if (!$results && ($viewer->getIsAdmin() == 1)) { - $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); - if (!$instance) { - $content = pht(<<setObjectList($list); - } else { - $content = id(new PHUIBoxView()) - ->appendChild(new PHUIRemarkupView($viewer, $content)) - ->addClass('mlt mlb msr msl'); - $view = new PhabricatorApplicationSearchResultView(); - $view->setContent($content); - } - } - - $title = pht('Recent Activity'); - $href = '/feed/'; - - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } public function buildRepositoryPanel() { + $panel = $this->newQueryPanel() + ->setName(pht('Active Repositories')) + ->setProperty('class', 'PhabricatorRepositorySearchEngine') + ->setProperty('key', 'active') + ->setProperty('limit', 5); + + return $this->renderPanel($panel); + } + + private function newQueryPanel() { + $panel_type = id(new PhabricatorDashboardQueryPanelType()) + ->getPanelTypeKey(); + + return id(new PhabricatorDashboardPanel()) + ->setPanelType($panel_type); + } + + private function renderPanel(PhabricatorDashboardPanel $panel) { $viewer = $this->getViewer(); - $engine = new PhabricatorRepositorySearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('active'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(5); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); - - $title = pht('Active Repositories'); - $href = '/diffusion/'; - - return $this->buildHomepagePanel($title, $href, $view); + return id(new PhabricatorDashboardPanelRenderingEngine()) + ->setViewer($viewer) + ->setPanel($panel) + ->setParentPanelPHIDs(array()) + ->renderPanel(); } } From 6b069f26c081c297baeb4c56ba427a2adbc3ff80 Mon Sep 17 00:00:00 2001 From: epriestley Date: Wed, 27 Mar 2019 08:24:15 -0700 Subject: [PATCH 39/52] Give Dashboard query panels a more obvious "View All" button Summary: Depends on D20333. Ref T13272. Currently, dashboard query panels have an aesthetic but hard-to-see icon to view results, with no text label. Instead, provide an easier-to-see button with a text label. Test Plan: {F6314091} Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13272 Differential Revision: https://secure.phabricator.com/D20334 --- .../PhabricatorDashboardQueryPanelType.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php index 0781d71b16..b8b9c2ceae 100644 --- a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php +++ b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php @@ -120,10 +120,18 @@ final class PhabricatorDashboardQueryPanelType $search_engine = $this->getSearchEngine($panel); $key = $panel->getProperty('key'); $href = $search_engine->getQueryResultsPageURI($key); + $icon = id(new PHUIIconView()) - ->setIcon('fa-search') - ->setHref($href); - $header->addActionItem($icon); + ->setIcon('fa-search'); + + $button = id(new PHUIButtonView()) + ->setTag('a') + ->setText(pht('View All')) + ->setIcon($icon) + ->setHref($href) + ->setColor(PHUIButtonView::GREY); + + $header->addActionLink($button); return $header; } From 2c184bd4cdbf3c1e9e0313227b47ded322ad25ae Mon Sep 17 00:00:00 2001 From: epriestley Date: Wed, 27 Mar 2019 08:43:18 -0700 Subject: [PATCH 40/52] When query panels overheat, degrade them and show a warning Summary: Depends on D20334. Ref T13272. After recent changes to make overheating queries throw by default, dashboard panels now fail into an error state when they overheat. This is a big step up from the hard-coded homepage panels removed by D20333, but can be improved. Let these panels render partial results when they overheat and show a human-readable warning. Test Plan: {F6314114} Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13272 Differential Revision: https://secure.phabricator.com/D20335 --- .../PhabricatorDashboardQueryPanelType.php | 32 +++++++++++++++++-- ...PhabricatorApplicationSearchController.php | 28 +++++++++++----- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php index b8b9c2ceae..3c77da48dc 100644 --- a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php +++ b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php @@ -106,9 +106,37 @@ final class PhabricatorDashboardQueryPanelType } } - $results = $engine->executeQuery($query, $pager); + $query->setReturnPartialResultsOnOverheat(true); - return $engine->renderResults($results, $saved); + $results = $engine->executeQuery($query, $pager); + $results_view = $engine->renderResults($results, $saved); + + $is_overheated = $query->getIsOverheated(); + $overheated_view = null; + if ($is_overheated) { + $content = $results_view->getContent(); + + $overheated_message = + PhabricatorApplicationSearchController::newOverheatedError( + (bool)$results); + + $overheated_warning = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setTitle(pht('Query Overheated')) + ->setErrors( + array( + $overheated_message, + )); + + $overheated_box = id(new PHUIBoxView()) + ->addClass('mmt mmb') + ->appendChild($overheated_warning); + + $content = array($content, $overheated_box); + $results_view->setContent($content); + } + + return $results_view; } public function adjustPanelHeader( diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php index 25cad5ac20..4bf4929f4b 100644 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -850,19 +850,31 @@ final class PhabricatorApplicationSearchController )); } - private function newOverheatedView(array $results) { - if ($results) { + public static function newOverheatedError($has_results) { + $overheated_link = phutil_tag( + 'a', + array( + 'href' => 'https://phurl.io/u/overheated', + 'target' => '_blank', + ), + pht('Learn More')); + + if ($has_results) { $message = pht( - 'Most objects matching your query are not visible to you, so '. - 'filtering results is taking a long time. Only some results are '. - 'shown. Refine your query to find results more quickly.'); + 'This query took too long, so only some results are shown. %s', + $overheated_link); } else { $message = pht( - 'Most objects matching your query are not visible to you, so '. - 'filtering results is taking a long time. Refine your query to '. - 'find results more quickly.'); + 'This query took too long. %s', + $overheated_link); } + return $message; + } + + private function newOverheatedView(array $results) { + $message = self::newOverheatedError((bool)$results); + return id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setFlush(true) From 73feac47c708db45b6fe5322b0dc9c7ddd0a2140 Mon Sep 17 00:00:00 2001 From: epriestley Date: Wed, 27 Mar 2019 10:40:23 -0700 Subject: [PATCH 41/52] When query panels have more results, show a "View All Results" button at the bottom Summary: Depends on D20335. Ref T13263. Ref T13272. See PHI854. Ref T9903. Currently, we don't provide a clear indicator that a query panel is showing a partial result set (UI looks the same whether there are more results or not). We also don't provide any way to get to the full result set (regardless of whether it is the same as the visible set or not) on tab panels, since they don't inherit the header buttons. To (mostly) fix these problems, add a "View All Results" button at the bottom of the list if the panel shows only a subset of results. Test Plan: {F6314560} {F6314562} {F6314564} Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13272, T13263, T9903 Differential Revision: https://secure.phabricator.com/D20336 --- .../PhabricatorDashboardQueryPanelType.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php index 3c77da48dc..a71263b27e 100644 --- a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php +++ b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php @@ -136,6 +136,33 @@ final class PhabricatorDashboardQueryPanelType $results_view->setContent($content); } + if ($pager->getHasMoreResults()) { + $item_list = $results_view->getObjectList(); + + $more_href = $engine->getQueryResultsPageURI($key); + if ($item_list) { + $item_list->newTailButton() + ->setHref($more_href); + } else { + // For search engines that do not return an object list, add a fake + // one to the end so we can render a "View All Results" button that + // looks like it does in normal applications. At time of writing, + // several major applications like Maniphest (which has group headers) + // and Feed (which uses custom rendering) don't return simple lists. + + $content = $results_view->getContent(); + + $more_list = id(new PHUIObjectItemListView()) + ->setAllowEmptyList(true); + + $more_list->newTailButton() + ->setHref($more_href); + + $content = array($content, $more_list); + $results_view->setContent($content); + } + } + return $results_view; } From 6648942bc885175cc59c07ce0be380170a4ce268 Mon Sep 17 00:00:00 2001 From: epriestley Date: Wed, 27 Mar 2019 11:25:27 -0700 Subject: [PATCH 42/52] Don't allow "Conpherence" menu items to be added to editable menus if Conpherence is not installed Summary: Depends on D20336. Ref T13272. Fixes T12745. If you uninstall Conpherence, "Edit Favorites" and other editable menu interfaces still allow you to add "Conpherence" items. Prevent this. Test Plan: - Installed Conpherence: Saw option to add a "Conpherence" link when editing favorites menu. - Uninstalled Conpherence: No more option. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13272, T12745 Differential Revision: https://secure.phabricator.com/D20337 --- .../menuitem/PhabricatorConpherenceProfileMenuItem.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php index 6a91188c8f..542c634958 100644 --- a/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php @@ -17,6 +17,12 @@ final class PhabricatorConpherenceProfileMenuItem } public function canAddToObject($object) { + $application = new PhabricatorConpherenceApplication(); + + if (!$application->isInstalled()) { + return false; + } + return true; } From 6bb9d3ac67a1b9d285ba351af5e5eeffdc8ac682 Mon Sep 17 00:00:00 2001 From: epriestley Date: Wed, 27 Mar 2019 14:09:37 -0700 Subject: [PATCH 43/52] Allow users to add "ProfileMenu" items on mobile Summary: Depends on D20337. Fixes T12167. Ref T13272. On this page ("Favorites > Edit Favorites > Personal", for example) the curtain actions aren't available on mobile. Normally, curtains are built with `Controller->newCurtainView()`, which sets an ID on the action list, which populates the header button. This curtain is built directly because there's no `Controller` handy. To fix the issue, just set an ID. This could probably be cleaner, but that's likely a much more involved change. Test Plan: Edited my favorites, narrowed the window, saw an "Actions" button. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13272, T12167 Differential Revision: https://secure.phabricator.com/D20338 --- .../search/engine/PhabricatorProfileMenuEngine.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/applications/search/engine/PhabricatorProfileMenuEngine.php b/src/applications/search/engine/PhabricatorProfileMenuEngine.php index 90f056ac4f..d5cb9ee43b 100644 --- a/src/applications/search/engine/PhabricatorProfileMenuEngine.php +++ b/src/applications/search/engine/PhabricatorProfileMenuEngine.php @@ -917,15 +917,16 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { $list->addItem($view); } - $action_view = id(new PhabricatorActionListView()) - ->setUser($viewer); - $item_types = PhabricatorProfileMenuItem::getAllMenuItems(); $object = $this->getProfileObject(); $action_list = id(new PhabricatorActionListView()) ->setViewer($viewer); + // See T12167. This makes the "Actions" dropdown button show up in the + // page header. + $action_list->setID(celerity_generate_unique_node_id()); + $action_list->addAction( id(new PhabricatorActionView()) ->setLabel(true) @@ -970,9 +971,6 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList($list); - $panel = id(new PHUICurtainPanelView()) - ->appendChild($action_view); - $curtain = id(new PHUICurtainView()) ->setViewer($viewer) ->setActionList($action_list); From e69b349b1b24c70ebf562359f655c76cd6773bd1 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 28 Mar 2019 08:53:07 -0700 Subject: [PATCH 44/52] Prevent users from removing task titles with "Bulk Edit" Summary: See downstream . The "Bulk Edit" flow works with `setContinueOnMissingFields(true)`, so `newRequiredError()` errors are ignored. This allows you to apply a transaction which changes the title to `""` (the empty string) without actually hitting any errors which the workflow respects. (Normally, `setContinueOnMissingFields(...)` workflows only edit properties that can't be missing, like the status of an object, so this is an unusual flow.) Instead, validate more narrowly: - Transactions which would remove the title get an "invalid" error, which is respected even under "setContinueOnMissingFields()". - Then, we try to raise a "missing/required" error if everything otherwise looks okay. Test Plan: - Edited a task title normally. - Edited a task to remove the title (got an error). - Created a task with no title (disallowed: got an error). - Bulk edited a task to remove its title. - Before change: allowed. - After change: disallowed. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20339 --- .../xaction/ManiphestTaskTitleTransaction.php | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php b/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php index e4ec2a132f..7dd9217760 100644 --- a/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php +++ b/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php @@ -64,9 +64,27 @@ final class ManiphestTaskTitleTransaction public function validateTransactions($object, array $xactions) { $errors = array(); - if ($this->isEmptyTextTransaction($object->getTitle(), $xactions)) { - $errors[] = $this->newRequiredError( - pht('Tasks must have a title.')); + // If the user is acting via "Bulk Edit" or another workflow which + // continues on missing fields, they may be applying a transaction which + // removes the task title. Mark these transactions as invalid first, + // then flag the missing field error if we don't find any more specific + // problems. + + foreach ($xactions as $xaction) { + $new = $xaction->getNewValue(); + if (!strlen($new)) { + $errors[] = $this->newInvalidError( + pht('Tasks must have a title.'), + $xaction); + continue; + } + } + + if (!$errors) { + if ($this->isEmptyTextTransaction($object->getTitle(), $xactions)) { + $errors[] = $this->newRequiredError( + pht('Tasks must have a title.')); + } } return $errors; From 15cc475cbd8c60d1c3524f0bda06f5fce7f762e7 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 28 Mar 2019 15:32:23 -0700 Subject: [PATCH 45/52] When a comment was signed with MFA, require MFA to edit it Summary: Ref PHI1173. Currently, you can edit an MFA'd comment without redoing MFA. This is inconsistent with the intent of the MFA badge, since it means an un-MFA'd comment may have an "MFA" badge on it. Instead, implement these rules: - If a comment was signed with MFA, you MUST MFA to edit it. - When removing a comment, add an extra MFA prompt if the user has MFA. This one isn't strictly required, this action is just very hard to undo and seems reasonable to MFA. Test Plan: - Made normal comments and MFA comments. - Edited normal comments and MFA comments (got prompted). - Removed normal comments and MFA comments (prompted in both cases). - Tried to edit an MFA comment without MFA on my account, got a hard "MFA absolutely required" failure. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20340 --- ...cationTransactionCommentEditController.php | 27 +++- ...tionTransactionCommentRemoveController.php | 11 +- ...torApplicationTransactionCommentEditor.php | 121 ++++++++++++++++++ ...habricatorApplicationTransactionEditor.php | 3 +- 4 files changed, 151 insertions(+), 11 deletions(-) diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php index a93f16a688..1682a7d136 100644 --- a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php +++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php @@ -29,7 +29,9 @@ final class PhabricatorApplicationTransactionCommentEditController $handles = $viewer->loadHandles(array($phid)); $obj_handle = $handles[$phid]; - if ($request->isDialogFormPost()) { + $done_uri = $obj_handle->getURI(); + + if ($request->isFormOrHisecPost()) { $text = $request->getStr('text'); $comment = $xaction->getApplicationTransactionCommentObject(); @@ -41,29 +43,42 @@ final class PhabricatorApplicationTransactionCommentEditController $editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($viewer) ->setContentSource(PhabricatorContentSource::newFromRequest($request)) + ->setRequest($request) + ->setCancelURI($done_uri) ->applyEdit($xaction, $comment); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent(array()); } else { - return id(new AphrontReloadResponse())->setURI($obj_handle->getURI()); + return id(new AphrontReloadResponse())->setURI($done_uri); } } + $errors = array(); + if ($xaction->getIsMFATransaction()) { + $message = pht( + 'This comment was signed with MFA, so you will be required to '. + 'provide MFA credentials to make changes.'); + + $errors[] = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_MFA) + ->setErrors(array($message)); + } + $form = id(new AphrontFormView()) ->setUser($viewer) ->setFullWidth(true) ->appendControl( id(new PhabricatorRemarkupControl()) - ->setName('text') - ->setValue($xaction->getComment()->getContent())); + ->setName('text') + ->setValue($xaction->getComment()->getContent())); return $this->newDialog() ->setTitle(pht('Edit Comment')) - ->addHiddenInput('anchor', $request->getStr('anchor')) + ->appendChild($errors) ->appendForm($form) ->addSubmitButton(pht('Save Changes')) - ->addCancelButton($obj_handle->getURI()); + ->addCancelButton($done_uri); } } diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php index c52b087273..381dfe1176 100644 --- a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php +++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php @@ -30,20 +30,24 @@ final class PhabricatorApplicationTransactionCommentRemoveController ->withPHIDs(array($obj_phid)) ->executeOne(); - if ($request->isDialogFormPost()) { + $done_uri = $obj_handle->getURI(); + + if ($request->isFormOrHisecPost()) { $comment = $xaction->getApplicationTransactionCommentObject() ->setContent('') ->setIsRemoved(true); $editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($viewer) + ->setRequest($request) + ->setCancelURI($done_uri) ->setContentSource(PhabricatorContentSource::newFromRequest($request)) ->applyEdit($xaction, $comment); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent(array()); } else { - return id(new AphrontReloadResponse())->setURI($obj_handle->getURI()); + return id(new AphrontReloadResponse())->setURI($done_uri); } } @@ -54,7 +58,6 @@ final class PhabricatorApplicationTransactionCommentRemoveController ->setTitle(pht('Remove Comment')); $dialog - ->addHiddenInput('anchor', $request->getStr('anchor')) ->appendParagraph( pht( "Removing a comment prevents anyone (including you) from reading ". @@ -65,7 +68,7 @@ final class PhabricatorApplicationTransactionCommentRemoveController $dialog ->addSubmitButton(pht('Remove Comment')) - ->addCancelButton($obj_handle->getURI()); + ->addCancelButton($done_uri); return $dialog; } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php index f9db0e238e..d963ea2ecb 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php @@ -5,6 +5,9 @@ final class PhabricatorApplicationTransactionCommentEditor private $contentSource; private $actingAsPHID; + private $request; + private $cancelURI; + private $isNewComment; public function setActingAsPHID($acting_as_phid) { $this->actingAsPHID = $acting_as_phid; @@ -27,6 +30,33 @@ final class PhabricatorApplicationTransactionCommentEditor return $this->contentSource; } + public function setRequest(AphrontRequest $request) { + $this->request = $request; + return $this; + } + + public function getRequest() { + return $this->request; + } + + public function setCancelURI($cancel_uri) { + $this->cancelURI = $cancel_uri; + return $this; + } + + public function getCancelURI() { + return $this->cancelURI; + } + + public function setIsNewComment($is_new) { + $this->isNewComment = $is_new; + return $this; + } + + public function getIsNewComment() { + return $this->isNewComment; + } + /** * Edit a transaction's comment. This method effects the required create, * update or delete to set the transaction's comment to the provided comment. @@ -39,6 +69,8 @@ final class PhabricatorApplicationTransactionCommentEditor $actor = $this->requireActor(); + $this->applyMFAChecks($xaction, $comment); + $comment->setContentSource($this->getContentSource()); $comment->setAuthorPHID($this->getActingAsPHID()); @@ -160,5 +192,94 @@ final class PhabricatorApplicationTransactionCommentEditor } } + private function applyMFAChecks( + PhabricatorApplicationTransaction $xaction, + PhabricatorApplicationTransactionComment $comment) { + $actor = $this->requireActor(); + + // We don't do any MFA checks here when you're creating a comment for the + // first time (the parent editor handles them for us), so we can just bail + // out if this is the creation flow. + if ($this->getIsNewComment()) { + return; + } + + $request = $this->getRequest(); + if (!$request) { + throw new PhutilInvalidStateException('setRequest'); + } + + $cancel_uri = $this->getCancelURI(); + if (!strlen($cancel_uri)) { + throw new PhutilInvalidStateException('setCancelURI'); + } + + // If you're deleting a comment, we try to prompt you for MFA if you have + // it configured, but do not require that you have it configured. In most + // cases, this is administrators removing content. + + // See PHI1173. If you're editing a comment you authored and the original + // comment was signed with MFA, you MUST have MFA on your account and you + // MUST sign the edit with MFA. Otherwise, we can end up with an MFA badge + // on different content than what was signed. + + $want_mfa = false; + $need_mfa = false; + + if ($comment->getIsRemoved()) { + // Try to prompt on removal. + $want_mfa = true; + } + + if ($xaction->getIsMFATransaction()) { + if ($actor->getPHID() === $xaction->getAuthorPHID()) { + // Strictly require MFA if the original transaction was signed and + // you're the author. + $want_mfa = true; + $need_mfa = true; + } + } + + if (!$want_mfa) { + return; + } + + if ($need_mfa) { + $factors = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($actor) + ->withUserPHIDs(array($this->getActingAsPHID())) + ->withFactorProviderStatuses( + array( + PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, + PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED, + )) + ->execute(); + if (!$factors) { + $error = new PhabricatorApplicationTransactionValidationError( + $xaction->getTransactionType(), + pht('No MFA'), + pht( + 'This comment was signed with MFA, so edits to it must also be '. + 'signed with MFA. You do not have any MFA factors attached to '. + 'your account, so you can not sign this edit. Add MFA to your '. + 'account in Settings.'), + $xaction); + + throw new PhabricatorApplicationTransactionValidationException( + array( + $error, + )); + } + } + + $workflow_key = sprintf( + 'comment.edit(%s, %d)', + $xaction->getPHID(), + $xaction->getComment()->getID()); + + $hisec_token = id(new PhabricatorAuthSessionEngine()) + ->setWorkflowKey($workflow_key) + ->requireHighSecurityToken($actor, $request, $cancel_uri); + } } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index 3e5e9c23c9..06c9b43216 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -1113,7 +1113,8 @@ abstract class PhabricatorApplicationTransactionEditor $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($actor) ->setActingAsPHID($this->getActingAsPHID()) - ->setContentSource($this->getContentSource()); + ->setContentSource($this->getContentSource()) + ->setIsNewComment(true); if (!$transaction_open) { $object->openTransaction(); From f1a7eb66da36acc6313002af6e51f092e699ea0f Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 28 Mar 2019 16:14:47 -0700 Subject: [PATCH 46/52] Fix a straggling issue with cursor changes impacting Conpherence thread indexing Summary: Ref T13266. Caught one more of these "directly setting afterID" issues in the logs. Test Plan: Ran `bin/search index --type ConpherenceThread` before and after changes. Before: fatal about a direct call. After: clean index rebuild. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13266 Differential Revision: https://secure.phabricator.com/D20341 --- .../ConpherenceThreadIndexEngineExtension.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php b/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php index d45e347729..740a1d81e2 100644 --- a/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php +++ b/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php @@ -51,13 +51,16 @@ final class ConpherenceThreadIndexEngineExtension ConpherenceThread $thread, ConpherenceTransaction $xaction) { - $previous = id(new ConpherenceTransactionQuery()) + $pager = id(new AphrontCursorPagerView()) + ->setPageSize(1) + ->setAfterID($xaction->getID()); + + $previous_xactions = id(new ConpherenceTransactionQuery()) ->setViewer($this->getViewer()) ->withObjectPHIDs(array($thread->getPHID())) ->withTransactionTypes(array(PhabricatorTransactions::TYPE_COMMENT)) - ->setAfterID($xaction->getID()) - ->setLimit(1) - ->executeOne(); + ->executeWithCursorPager($pager); + $previous = head($previous_xactions); $index = id(new ConpherenceIndex()) ->setThreadPHID($thread->getPHID()) From 953a449305bbd073207602f070b73bd917db86d5 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 28 Mar 2019 16:21:24 -0700 Subject: [PATCH 47/52] Hide "Availability" and "Calendar" on user profiles for disabled users Summary: See downstream . That suggestion is a little light on details, but I basically agree that showing "Availability: Available" on disabled user profiles is kind of questionable/misleading. Just hide event information on disabled profiles, since this doesn't seem worth building a special "Availability: Who Knows, They Are Disabled, Good Luck" disabled state for. Test Plan: Looked at disabled and non-disabled user profiles, saw Calendar stuff only on the former. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20342 --- .../controller/PhabricatorPeopleProfileViewController.php | 6 ++++++ .../people/customfield/PhabricatorUserStatusField.php | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/applications/people/controller/PhabricatorPeopleProfileViewController.php b/src/applications/people/controller/PhabricatorPeopleProfileViewController.php index 0c32075932..6a4d68d6aa 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileViewController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileViewController.php @@ -175,6 +175,12 @@ final class PhabricatorPeopleProfileViewController return null; } + // Don't show calendar information for disabled users, since it's probably + // not useful or accurate and may be misleading. + if ($user->getIsDisabled()) { + return null; + } + $midnight = PhabricatorTime::getTodayMidnightDateTime($viewer); $week_end = clone $midnight; $week_end = $week_end->modify('+3 days'); diff --git a/src/applications/people/customfield/PhabricatorUserStatusField.php b/src/applications/people/customfield/PhabricatorUserStatusField.php index 2ae9158566..1716e8e198 100644 --- a/src/applications/people/customfield/PhabricatorUserStatusField.php +++ b/src/applications/people/customfield/PhabricatorUserStatusField.php @@ -30,6 +30,12 @@ final class PhabricatorUserStatusField $user = $this->getObject(); $viewer = $this->requireViewer(); + // Don't show availability for disabled users, since this is vaguely + // misleading to say "Availability: Available" and probably not useful. + if ($user->getIsDisabled()) { + return null; + } + return id(new PHUIUserAvailabilityView()) ->setViewer($viewer) ->setAvailableUser($user); From eecee172139eb7e4b94307cb70ca515d09aa5ea7 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 28 Mar 2019 16:49:03 -0700 Subject: [PATCH 48/52] Activate "jx-toggle-class" on click to fix broken mobile behavior Summary: See downstream . Searching for things on mobile is a significant challenge because clicking the "Magnifying Glass" icon shows and then immediately hides the menu. I believe some aspect of iOS event handling has changed since this was originally written. At some point, I'd like to rewrite this to work more cleanly and get rid of `jx-toggle-class`. In particular, it isn't smart enough to know that it should be modal with other menus, so you can get states like this by clicking multiple things: {F6320110} This would also probably just look and work better if it was an inline element that showed up under the header instead of a floating dropdown element. However, I'm having a hard time getting the Safari debugger to actually connect to the iOS simulator, so take a small step toward this bright future and fix the immediate problem for now: toggle on click instead of mousedown/touchstart. This means the menu opens ~100ms later, but actually works. Big improvement! I'd like to move away from "jx-toggle-class" anyway (it usually isn't sophisticated enough to fully describe a behavior) so reducing complexity here seems good. It isn't used in //too// many places so this is unlikely to have any negative effects, I hope. Test Plan: On iOS simulator, clicked the magnifying glass icon in the main menu to get a search input. Before: got a search input for a microsecond. After: actually got a search input. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20343 --- resources/celerity/map.php | 16 ++++++++-------- webroot/rsrc/js/core/behavior-toggle-class.js | 11 +---------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index c1a77bca11..a08ef54102 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -10,7 +10,7 @@ return array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', 'core.pkg.css' => '2dd936d6', - 'core.pkg.js' => 'eb53fc5b', + 'core.pkg.js' => 'a747b035', 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', 'diffusion.pkg.css' => '42c75c37', @@ -498,7 +498,7 @@ return array( 'rsrc/js/core/behavior-select-on-click.js' => '66365ee2', 'rsrc/js/core/behavior-setup-check-https.js' => '01384686', 'rsrc/js/core/behavior-time-typeahead.js' => '5803b9e7', - 'rsrc/js/core/behavior-toggle-class.js' => 'f5c78ae3', + 'rsrc/js/core/behavior-toggle-class.js' => '32db8374', 'rsrc/js/core/behavior-tokenizer.js' => '3b4899b0', 'rsrc/js/core/behavior-tooltip.js' => '73ecc1f8', 'rsrc/js/core/behavior-user-menu.js' => '60cd9241', @@ -687,7 +687,7 @@ return array( 'javelin-behavior-stripe-payment-form' => '02cb4398', 'javelin-behavior-test-payment-form' => '4a7fb02b', 'javelin-behavior-time-typeahead' => '5803b9e7', - 'javelin-behavior-toggle-class' => 'f5c78ae3', + 'javelin-behavior-toggle-class' => '32db8374', 'javelin-behavior-toggle-widget' => '8f959ad0', 'javelin-behavior-trigger-rule-editor' => '398fdf13', 'javelin-behavior-typeahead-browse' => '70245195', @@ -1186,6 +1186,11 @@ return array( 'javelin-install', 'javelin-util', ), + '32db8374' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-dom', + ), 34450586 => array( 'javelin-color', 'javelin-install', @@ -2149,11 +2154,6 @@ return array( 'javelin-stratcom', 'javelin-dom', ), - 'f5c78ae3' => array( - 'javelin-behavior', - 'javelin-stratcom', - 'javelin-dom', - ), 'f84bcbf4' => array( 'javelin-behavior', 'javelin-stratcom', diff --git a/webroot/rsrc/js/core/behavior-toggle-class.js b/webroot/rsrc/js/core/behavior-toggle-class.js index d4756eb6bb..18663b0487 100644 --- a/webroot/rsrc/js/core/behavior-toggle-class.js +++ b/webroot/rsrc/js/core/behavior-toggle-class.js @@ -17,7 +17,7 @@ JX.behavior('toggle-class', function(config, statics) { function install() { JX.Stratcom.listen( - ['touchstart', 'mousedown'], + 'click', 'jx-toggle-class', function(e) { e.kill(); @@ -29,15 +29,6 @@ JX.behavior('toggle-class', function(config, statics) { } }); - // Swallow the regular click handler event so e.g. Quicksand - // click handler doesn't get a hold of it - JX.Stratcom.listen( - ['click'], - 'jx-toggle-class', - function(e) { - e.kill(); - }); - return true; } From c4856c37e7a6f0cd279418f8ab72750c5f08ebdf Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 28 Mar 2019 17:04:27 -0700 Subject: [PATCH 49/52] Fix content overflow in user hovercards Summary: Fixes T13273. This element is a bit weird, but I think I fixed it without breaking anything. The CSS is used by project hovercards and user hovercards, but they each have a class which builds mostly-shared-but-not-really-identical CSS, instead of having a single `View` class with modes. So I'm not 100% sure I didn't break something obscure, but I couldn't find anything this breaks. The major issue is that all the text content has "position: absolute". Instead, make the image "absolute" and the text actual positioned content. Then fix all the margins/padding/spacing/layout and add overflow. Seems to work? Plus: hide availability for disabled users, for consistency with D20342. Test Plan: Before: {F6320155} After: {F6320156} I think this is pixel-exact except for the overflow behavior. Also: - Viewed some other user hovercards, including a disabled user. They all looked unchanged. - Viewed some project hovercards. They all looked good, too. Reviewers: amckinley Reviewed By: amckinley Maniphest Tasks: T13273 Differential Revision: https://secure.phabricator.com/D20344 --- resources/celerity/map.php | 4 ++-- .../people/view/PhabricatorUserCardView.php | 21 +++++++++------- .../application/project/project-card-view.css | 24 +++++++++++++++---- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index a08ef54102..fce6b89474 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -99,7 +99,7 @@ return array( 'rsrc/css/application/policy/policy-transaction-detail.css' => 'c02b8384', 'rsrc/css/application/policy/policy.css' => 'ceb56a08', 'rsrc/css/application/ponder/ponder-view.css' => '05a09d0a', - 'rsrc/css/application/project/project-card-view.css' => '3b1f7b20', + 'rsrc/css/application/project/project-card-view.css' => '4e7371cd', 'rsrc/css/application/project/project-triggers.css' => 'cb866c2d', 'rsrc/css/application/project/project-view.css' => '567858b3', 'rsrc/css/application/releeph/releeph-core.css' => 'f81ff2db', @@ -881,7 +881,7 @@ return array( 'policy-edit-css' => '8794e2ed', 'policy-transaction-detail-css' => 'c02b8384', 'ponder-view-css' => '05a09d0a', - 'project-card-view-css' => '3b1f7b20', + 'project-card-view-css' => '4e7371cd', 'project-triggers-css' => 'cb866c2d', 'project-view-css' => '567858b3', 'releeph-core' => 'f81ff2db', diff --git a/src/applications/people/view/PhabricatorUserCardView.php b/src/applications/people/view/PhabricatorUserCardView.php index f1fc515f88..21cb468ba8 100644 --- a/src/applications/people/view/PhabricatorUserCardView.php +++ b/src/applications/people/view/PhabricatorUserCardView.php @@ -95,14 +95,17 @@ final class PhabricatorUserCardView extends AphrontTagView { 'fa-user-plus', phabricator_date($user->getDateCreated(), $viewer)); - if (PhabricatorApplication::isClassInstalledForViewer( - 'PhabricatorCalendarApplication', - $viewer)) { - $body[] = $this->addItem( - 'fa-calendar-o', - id(new PHUIUserAvailabilityView()) - ->setViewer($viewer) - ->setAvailableUser($user)); + $has_calendar = PhabricatorApplication::isClassInstalledForViewer( + 'PhabricatorCalendarApplication', + $viewer); + if ($has_calendar) { + if (!$user->getIsDisabled()) { + $body[] = $this->addItem( + 'fa-calendar-o', + id(new PHUIUserAvailabilityView()) + ->setViewer($viewer) + ->setAvailableUser($user)); + } } $classes[] = 'project-card-image'; @@ -150,8 +153,8 @@ final class PhabricatorUserCardView extends AphrontTagView { 'class' => 'project-card-inner', ), array( - $image, $header, + $image, )); return $card; diff --git a/webroot/rsrc/css/application/project/project-card-view.css b/webroot/rsrc/css/application/project/project-card-view.css index b960d55cef..cce4789ef7 100644 --- a/webroot/rsrc/css/application/project/project-card-view.css +++ b/webroot/rsrc/css/application/project/project-card-view.css @@ -36,22 +36,36 @@ } .project-card-view .project-card-image { + position: absolute; height: 140px; width: 140px; - margin: 6px; + top: 6px; + left: 6px; border-radius: 3px; } .project-card-view .project-card-image-href { - display: inline-block; + display: block; } .project-card-view .project-card-item div { display: inline; } +.project-card-inner { + position: relative; +} + +.people-card-view .project-card-inner { + padding: 6px; + min-height: 140px; +} + .project-card-view .project-card-item { margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .project-card-view .project-card-item-text { @@ -63,9 +77,9 @@ } .project-card-view .project-card-header { - position: absolute; - top: 12px; - left: 158px; + margin-top: 6px; + margin-left: 152px; + overflow: hidden; } .project-card-header .project-card-name { From e586ed439a790f2087948ecf701695cad9955700 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 28 Mar 2019 17:39:28 -0700 Subject: [PATCH 50/52] Improve overflow/ellipsis behaivor for very wide task graphs Summary: See downstream . The `T123 Task Name` column in graphs can currently fold down to 0 pixels wide. Although it's visually nice to render this element without a scroll bar when we don't really need one, the current behavior is excessive and not very useful. Instead, tweak the CSS so: - This cell is always at least 320px wide. - After 320px, we'll overflow/ellipsis the cell on small screens. This generally gives us better behavior: - Small screens get a scrollbar to see a reasonable amount of content. - The UI doesn't turn into a total mess if one task has a whole novel of text. Test Plan: Old behavior, note that there's no scrollbar and the cell is so narrow it is useless: {F6320208} New behavior, same default view, has a scrollbar: {F6320209} Scrolling over gives you this: {F6320210} On a wider screen (this wide or better), we don't need to scroll: {F6320211} Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20345 --- resources/celerity/map.php | 6 +++--- webroot/rsrc/css/aphront/table-view.css | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index fce6b89474..4912f91b08 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -9,7 +9,7 @@ return array( 'names' => array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', - 'core.pkg.css' => '2dd936d6', + 'core.pkg.css' => '7e6e954b', 'core.pkg.js' => 'a747b035', 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', @@ -30,7 +30,7 @@ return array( 'rsrc/css/aphront/notification.css' => '30240bd2', 'rsrc/css/aphront/panel-view.css' => '46923d46', 'rsrc/css/aphront/phabricator-nav-view.css' => 'f8a0c1bf', - 'rsrc/css/aphront/table-view.css' => '205053cd', + 'rsrc/css/aphront/table-view.css' => '7dc3a9c2', 'rsrc/css/aphront/tokenizer.css' => 'b52d0668', 'rsrc/css/aphront/tooltip.css' => 'e3f2412f', 'rsrc/css/aphront/typeahead-browse.css' => 'b7ed02d2', @@ -531,7 +531,7 @@ return array( 'aphront-list-filter-view-css' => 'feb64255', 'aphront-multi-column-view-css' => 'fbc00ba3', 'aphront-panel-view-css' => '46923d46', - 'aphront-table-view-css' => '205053cd', + 'aphront-table-view-css' => '7dc3a9c2', 'aphront-tokenizer-control-css' => 'b52d0668', 'aphront-tooltip-css' => 'e3f2412f', 'aphront-typeahead-control-css' => '8779483d', diff --git a/webroot/rsrc/css/aphront/table-view.css b/webroot/rsrc/css/aphront/table-view.css index a08674cf14..fd1a918148 100644 --- a/webroot/rsrc/css/aphront/table-view.css +++ b/webroot/rsrc/css/aphront/table-view.css @@ -229,7 +229,8 @@ span.single-display-line-content { word-wrap: break-word; overflow: hidden; text-overflow: ellipsis; - max-width: 0; + min-width: 320px; + max-width: 320px; } .aphront-table-view tr.closed td.object-link .object-name, From cec779cdabeece5f4a93d746939062ba1f34a577 Mon Sep 17 00:00:00 2001 From: epriestley Date: Thu, 28 Mar 2019 17:49:00 -0700 Subject: [PATCH 51/52] When drawing a very wide graph line diagram, smush it together a bit Summary: Depends on D20345. Use a narrower layout for very large graphs to save some space. Test Plan: Before: {F6320215} After: {F6320216} This does not affect smaller graphs. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20346 --- resources/celerity/map.php | 16 ++++++++-------- .../diffusion/behavior-commit-graph.js | 10 +++++++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 4912f91b08..1f36bf03b4 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -14,7 +14,7 @@ return array( 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', 'diffusion.pkg.css' => '42c75c37', - 'diffusion.pkg.js' => '91192d85', + 'diffusion.pkg.js' => 'a98c0bf7', 'maniphest.pkg.css' => '35995d6d', 'maniphest.pkg.js' => 'c9308721', 'rsrc/audio/basic/alert.mp3' => '17889334', @@ -384,7 +384,7 @@ return array( 'rsrc/js/application/diffusion/DiffusionLocateFileSource.js' => '94243d89', 'rsrc/js/application/diffusion/behavior-audit-preview.js' => 'b7b73831', 'rsrc/js/application/diffusion/behavior-commit-branches.js' => '4b671572', - 'rsrc/js/application/diffusion/behavior-commit-graph.js' => '1c88f154', + 'rsrc/js/application/diffusion/behavior-commit-graph.js' => 'ef836bf2', 'rsrc/js/application/diffusion/behavior-locate-file.js' => '87428eb2', 'rsrc/js/application/diffusion/behavior-pull-lastmodified.js' => 'c715c123', 'rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js' => '6a85bc5a', @@ -606,7 +606,7 @@ return array( 'javelin-behavior-differential-diff-radios' => '925fe8cd', 'javelin-behavior-differential-populate' => 'dfa1d313', 'javelin-behavior-diffusion-commit-branches' => '4b671572', - 'javelin-behavior-diffusion-commit-graph' => '1c88f154', + 'javelin-behavior-diffusion-commit-graph' => 'ef836bf2', 'javelin-behavior-diffusion-locate-file' => '87428eb2', 'javelin-behavior-diffusion-pull-lastmodified' => 'c715c123', 'javelin-behavior-document-engine' => '243d6c22', @@ -1033,11 +1033,6 @@ return array( 'javelin-install', 'javelin-util', ), - '1c88f154' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-stratcom', - ), '1cab0e9a' => array( 'javelin-behavior', 'javelin-dom', @@ -2124,6 +2119,11 @@ return array( 'phabricator-keyboard-shortcut', 'javelin-stratcom', ), + 'ef836bf2' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-stratcom', + ), 'f166c949' => array( 'javelin-behavior', 'javelin-behavior-device', diff --git a/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js b/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js index 309f972324..5c4591b542 100644 --- a/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js +++ b/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js @@ -44,11 +44,19 @@ JX.behavior('diffusion-commit-graph', function(config) { cxt.stroke(); } + // If the graph is going to be wide, squish it a bit so it doesn't take up + // quite as much space. + var default_width; + if (config.count >= 8) { + default_width = 6; + } else { + default_width = 12; + } for (var ii = 0; ii < nodes.length; ii++) { var data = JX.Stratcom.getData(nodes[ii]); - var cell = 12; // Width of each thread. + var cell = default_width; var xpos = function(col) { return (col * cell) + (cell / 2); }; From 02f94cd7d2885de502d03934af3a0c24453e8e58 Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 29 Mar 2019 09:50:50 -0700 Subject: [PATCH 52/52] Fix an issue with Duo not live-updating properly on login gates Summary: See . The "live update Duo status" endpoint currently requires full sessions, and doesn't work from the session upgrade gate on login. Don't require a full session to check the status of an MFA challenge. Test Plan: Went through Duo gate in a new session, got a live update. Reviewers: amckinley Reviewed By: amckinley Differential Revision: https://secure.phabricator.com/D20347 --- .../mfa/PhabricatorAuthChallengeStatusController.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php b/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php index 884bbaad6d..3fbffabc89 100644 --- a/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php +++ b/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php @@ -3,6 +3,12 @@ final class PhabricatorAuthChallengeStatusController extends PhabricatorAuthController { + public function shouldAllowPartialSessions() { + // We expect that users may request the status of an MFA challenge when + // they hit the session upgrade gate on login. + return true; + } + public function handleRequest(AphrontRequest $request) { $viewer = $this->getViewer(); $id = $request->getURIData('id');