@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Allow workboard sorting and filtering to be saved as defaults

Summary:
Fixes T6641. This allows users who have permission to edit a project to use "Save as Default" to save the current order and filter as defaults for the project.

These are per-board defaults, and apply to all users. The rationale is that I think the best default ordering/filtering depends mostly on the board, not the viewer.

This seems to align with most requests in the task, although rationale is a bit light. But, for example, it seems reasonable you might want to change the default filter to "All Tasks" on a sprint board, so you can see what's in the "Done" column.

This also fixes some minor issues I ran into:

- Herald could hit an issue while checking permissions if the project was a subproject and a non-member had a triggering rule.
- "Advanced filter..." did not prefill with the current filter.

Test Plan:
- Set default order and filter on a workboard.
- Reloaded board, saw settings stick.
- Tried to edit a board as an unprivileged user (disabled menu items, error).
- Reviewed transaction log.

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T6641

Differential Revision: https://secure.phabricator.com/D15260

+293 -32
+2
resources/sql/autopatches/20160212.proj.1.sql
··· 1 + ALTER TABLE {$NAMESPACE}_project.project 2 + ADD properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT};
+2
resources/sql/autopatches/20160212.proj.2.sql
··· 1 + UPDATE {$NAMESPACE}_project.project 2 + SET properties = '{}' WHERE properties = '';
+2
src/__phutil_library_map__.php
··· 2896 2896 'PhabricatorProjectCustomFieldStringIndex' => 'applications/project/storage/PhabricatorProjectCustomFieldStringIndex.php', 2897 2897 'PhabricatorProjectDAO' => 'applications/project/storage/PhabricatorProjectDAO.php', 2898 2898 'PhabricatorProjectDatasource' => 'applications/project/typeahead/PhabricatorProjectDatasource.php', 2899 + 'PhabricatorProjectDefaultController' => 'applications/project/controller/PhabricatorProjectDefaultController.php', 2899 2900 'PhabricatorProjectDescriptionField' => 'applications/project/customfield/PhabricatorProjectDescriptionField.php', 2900 2901 'PhabricatorProjectDetailsProfilePanel' => 'applications/project/profilepanel/PhabricatorProjectDetailsProfilePanel.php', 2901 2902 'PhabricatorProjectEditController' => 'applications/project/controller/PhabricatorProjectEditController.php', ··· 7332 7333 'PhabricatorProjectCustomFieldStringIndex' => 'PhabricatorCustomFieldStringIndexStorage', 7333 7334 'PhabricatorProjectDAO' => 'PhabricatorLiskDAO', 7334 7335 'PhabricatorProjectDatasource' => 'PhabricatorTypeaheadDatasource', 7336 + 'PhabricatorProjectDefaultController' => 'PhabricatorProjectBoardController', 7335 7337 'PhabricatorProjectDescriptionField' => 'PhabricatorProjectStandardCustomField', 7336 7338 'PhabricatorProjectDetailsProfilePanel' => 'PhabricatorProfilePanel', 7337 7339 'PhabricatorProjectEditController' => 'PhabricatorProjectController',
+2
src/applications/project/application/PhabricatorProjectApplication.php
··· 94 94 => 'PhabricatorProjectSilenceController', 95 95 'warning/(?P<id>[1-9]\d*)/' 96 96 => 'PhabricatorProjectSubprojectWarningController', 97 + 'default/(?P<projectID>[1-9]\d*)/(?P<target>[^/]+)/' 98 + => 'PhabricatorProjectDefaultController', 97 99 ), 98 100 '/tag/' => array( 99 101 '(?P<slug>[^/]+)/' => 'PhabricatorProjectViewController',
+106 -17
src/applications/project/controller/PhabricatorProjectBoardViewController.php
··· 8 8 private $id; 9 9 private $slug; 10 10 private $queryKey; 11 - private $filter; 12 11 private $sortKey; 13 12 private $showHidden; 14 13 ··· 56 55 $search_engine->getQueryResultsPageURI($saved->getQueryKey()))); 57 56 } 58 57 59 - $query_key = $request->getURIData('queryKey'); 60 - if (!$query_key) { 61 - $query_key = 'open'; 58 + $query_key = $this->getDefaultFilter($project); 59 + 60 + $request_query = $request->getStr('filter'); 61 + if (strlen($request_query)) { 62 + $query_key = $request_query; 62 63 } 64 + 65 + $uri_query = $request->getURIData('queryKey'); 66 + if (strlen($uri_query)) { 67 + $query_key = $uri_query; 68 + } 69 + 63 70 $this->queryKey = $query_key; 64 71 65 72 $custom_query = null; ··· 382 389 383 390 $sort_menu = $this->buildSortMenu( 384 391 $viewer, 392 + $project, 385 393 $this->sortKey); 386 394 387 395 $filter_menu = $this->buildFilterMenu( 388 396 $viewer, 397 + $project, 389 398 $custom_query, 390 399 $search_engine, 391 400 $query_key); ··· 445 454 $this->showHidden = $request->getBool('hidden'); 446 455 $this->id = $project->getID(); 447 456 448 - $sort_key = $request->getStr('order'); 449 - switch ($sort_key) { 457 + $sort_key = $this->getDefaultSort($project); 458 + 459 + $request_sort = $request->getStr('order'); 460 + if ($this->isValidSort($request_sort)) { 461 + $sort_key = $request_sort; 462 + } 463 + 464 + $this->sortKey = $sort_key; 465 + } 466 + 467 + private function getDefaultSort(PhabricatorProject $project) { 468 + $default_sort = $project->getDefaultWorkboardSort(); 469 + 470 + if ($this->isValidSort($default_sort)) { 471 + return $default_sort; 472 + } 473 + 474 + return PhabricatorProjectColumn::DEFAULT_ORDER; 475 + } 476 + 477 + private function getDefaultFilter(PhabricatorProject $project) { 478 + $default_filter = $project->getDefaultWorkboardFilter(); 479 + 480 + if (strlen($default_filter)) { 481 + return $default_filter; 482 + } 483 + 484 + return 'open'; 485 + } 486 + 487 + private function isValidSort($sort) { 488 + switch ($sort) { 450 489 case PhabricatorProjectColumn::ORDER_NATURAL: 451 490 case PhabricatorProjectColumn::ORDER_PRIORITY: 452 - break; 453 - default: 454 - $sort_key = PhabricatorProjectColumn::DEFAULT_ORDER; 455 - break; 491 + return true; 456 492 } 457 - $this->sortKey = $sort_key; 493 + 494 + return false; 458 495 } 459 496 460 497 private function buildSortMenu( 461 498 PhabricatorUser $viewer, 499 + PhabricatorProject $project, 462 500 $sort_key) { 463 501 464 502 $sort_icon = id(new PHUIIconView()) ··· 489 527 $items[] = $item; 490 528 } 491 529 530 + $id = $project->getID(); 531 + 532 + $save_uri = "default/{$id}/sort/"; 533 + $save_uri = $this->getApplicationURI($save_uri); 534 + $save_uri = $this->getURIWithState($save_uri, $force = true); 535 + 536 + $can_edit = PhabricatorPolicyFilter::hasCapability( 537 + $viewer, 538 + $project, 539 + PhabricatorPolicyCapability::CAN_EDIT); 540 + 541 + $items[] = id(new PhabricatorActionView()) 542 + ->setIcon('fa-floppy-o') 543 + ->setName(pht('Save as Default')) 544 + ->setHref($save_uri) 545 + ->setWorkflow(true) 546 + ->setDisabled(!$can_edit); 547 + 492 548 $sort_menu = id(new PhabricatorActionListView()) 493 549 ->setUser($viewer); 494 550 foreach ($items as $item) { ··· 507 563 508 564 return $sort_button; 509 565 } 566 + 510 567 private function buildFilterMenu( 511 568 PhabricatorUser $viewer, 569 + PhabricatorProject $project, 512 570 $custom_query, 513 571 PhabricatorApplicationSearchEngine $engine, 514 572 $query_key) { ··· 551 609 $uri = $engine->getQueryResultsPageURI($key); 552 610 } 553 611 554 - $uri = $this->getURIWithState($uri); 612 + $uri = $this->getURIWithState($uri) 613 + ->setQueryParam('filter', null); 555 614 $item->setHref($uri); 556 615 557 616 $items[] = $item; 558 617 } 559 618 619 + $id = $project->getID(); 620 + 621 + $filter_uri = $this->getApplicationURI("board/{$id}/filter/"); 622 + $filter_uri = $this->getURIWithState($filter_uri, $force = true); 623 + 560 624 $items[] = id(new PhabricatorActionView()) 561 625 ->setIcon('fa-cog') 562 - ->setHref($this->getApplicationURI('board/'.$this->id.'/filter/')) 626 + ->setHref($filter_uri) 563 627 ->setWorkflow(true) 564 628 ->setName(pht('Advanced Filter...')); 629 + 630 + $save_uri = "default/{$id}/filter/"; 631 + $save_uri = $this->getApplicationURI($save_uri); 632 + $save_uri = $this->getURIWithState($save_uri, $force = true); 633 + 634 + $can_edit = PhabricatorPolicyFilter::hasCapability( 635 + $viewer, 636 + $project, 637 + PhabricatorPolicyCapability::CAN_EDIT); 638 + 639 + $items[] = id(new PhabricatorActionView()) 640 + ->setIcon('fa-floppy-o') 641 + ->setName(pht('Save as Default')) 642 + ->setHref($save_uri) 643 + ->setWorkflow(true) 644 + ->setDisabled(!$can_edit); 565 645 566 646 $filter_menu = id(new PhabricatorActionListView()) 567 647 ->setUser($viewer); ··· 793 873 * the rest of the board state persistent. If no URI is provided, this method 794 874 * starts with the request URI. 795 875 * 796 - * @param string|null URI to add state parameters to. 797 - * @return PhutilURI URI with state parameters. 876 + * @param string|null URI to add state parameters to. 877 + * @param bool True to explicitly include all state. 878 + * @return PhutilURI URI with state parameters. 798 879 */ 799 - private function getURIWithState($base = null) { 880 + private function getURIWithState($base = null, $force = false) { 881 + $project = $this->getProject(); 882 + 800 883 if ($base === null) { 801 884 $base = $this->getRequest()->getRequestURI(); 802 885 } 803 886 804 887 $base = new PhutilURI($base); 805 888 806 - if ($this->sortKey != PhabricatorProjectColumn::DEFAULT_ORDER) { 889 + if ($force || ($this->sortKey != $this->getDefaultSort($project))) { 807 890 $base->setQueryParam('order', $this->sortKey); 808 891 } else { 809 892 $base->setQueryParam('order', null); 893 + } 894 + 895 + if ($force || ($this->queryKey != $this->getDefaultFilter($project))) { 896 + $base->setQueryParam('filter', $this->queryKey); 897 + } else { 898 + $base->setQueryParam('filter', null); 810 899 } 811 900 812 901 $base->setQueryParam('hidden', $this->showHidden ? 'true' : null);
+90
src/applications/project/controller/PhabricatorProjectDefaultController.php
··· 1 + <?php 2 + 3 + final class PhabricatorProjectDefaultController 4 + extends PhabricatorProjectBoardController { 5 + 6 + public function handleRequest(AphrontRequest $request) { 7 + $viewer = $request->getViewer(); 8 + $project_id = $request->getURIData('projectID'); 9 + 10 + $project = id(new PhabricatorProjectQuery()) 11 + ->setViewer($viewer) 12 + ->requireCapabilities( 13 + array( 14 + PhabricatorPolicyCapability::CAN_VIEW, 15 + PhabricatorPolicyCapability::CAN_EDIT, 16 + )) 17 + ->withIDs(array($project_id)) 18 + ->executeOne(); 19 + if (!$project) { 20 + return new Aphront404Response(); 21 + } 22 + $this->setProject($project); 23 + 24 + $target = $request->getURIData('target'); 25 + switch ($target) { 26 + case 'filter': 27 + $title = pht('Set Board Default Filter'); 28 + $body = pht( 29 + 'Make the current filter the new default filter for this board? '. 30 + 'All users will see the new filter as the default when they view '. 31 + 'the board.'); 32 + $button = pht('Save Default Filter'); 33 + 34 + $xaction_value = $request->getStr('filter'); 35 + $xaction_type = PhabricatorProjectTransaction::TYPE_DEFAULT_FILTER; 36 + break; 37 + case 'sort': 38 + $title = pht('Set Board Default Order'); 39 + $body = pht( 40 + 'Make the current sort order the new default order for this board? '. 41 + 'All users will see the new order as the default when they view '. 42 + 'the board.'); 43 + $button = pht('Save Default Order'); 44 + 45 + $xaction_value = $request->getStr('order'); 46 + $xaction_type = PhabricatorProjectTransaction::TYPE_DEFAULT_SORT; 47 + break; 48 + default: 49 + return new Aphront404Response(); 50 + } 51 + 52 + $id = $project->getID(); 53 + 54 + $view_uri = $this->getApplicationURI("board/{$id}/"); 55 + $view_uri = new PhutilURI($view_uri); 56 + foreach ($request->getPassthroughRequestData() as $key => $value) { 57 + $view_uri->setQueryParam($key, $value); 58 + } 59 + 60 + if ($request->isFormPost()) { 61 + $xactions = array(); 62 + 63 + $xactions[] = id(new PhabricatorProjectTransaction()) 64 + ->setTransactionType($xaction_type) 65 + ->setNewValue($xaction_value); 66 + 67 + id(new PhabricatorProjectTransactionEditor()) 68 + ->setActor($viewer) 69 + ->setContentSourceFromRequest($request) 70 + ->setContinueOnNoEffect(true) 71 + ->setContinueOnMissingFields(true) 72 + ->applyTransactions($project, $xactions); 73 + 74 + return id(new AphrontRedirectResponse())->setURI($view_uri); 75 + } 76 + 77 + $dialog = $this->newDialog() 78 + ->setTitle($title) 79 + ->appendChild($body) 80 + ->setDisableWorkflowOnCancel(true) 81 + ->addCancelButton($view_uri) 82 + ->addSubmitButton($title); 83 + 84 + foreach ($request->getPassthroughRequestData() as $key => $value) { 85 + $dialog->addHiddenInput($key, $value); 86 + } 87 + 88 + return $dialog; 89 + } 90 + }
+25 -1
src/applications/project/editor/PhabricatorProjectTransactionEditor.php
··· 40 40 $types[] = PhabricatorProjectTransaction::TYPE_PARENT; 41 41 $types[] = PhabricatorProjectTransaction::TYPE_MILESTONE; 42 42 $types[] = PhabricatorProjectTransaction::TYPE_HASWORKBOARD; 43 + $types[] = PhabricatorProjectTransaction::TYPE_DEFAULT_SORT; 44 + $types[] = PhabricatorProjectTransaction::TYPE_DEFAULT_FILTER; 43 45 44 46 return $types; 45 47 } ··· 71 73 case PhabricatorProjectTransaction::TYPE_PARENT: 72 74 case PhabricatorProjectTransaction::TYPE_MILESTONE: 73 75 return null; 76 + case PhabricatorProjectTransaction::TYPE_DEFAULT_SORT: 77 + return $object->getDefaultWorkboardSort(); 78 + case PhabricatorProjectTransaction::TYPE_DEFAULT_FILTER: 79 + return $object->getDefaultWorkboardFilter(); 74 80 } 75 81 76 82 return parent::getCustomTransactionOldValue($object, $xaction); ··· 89 95 case PhabricatorProjectTransaction::TYPE_LOCKED: 90 96 case PhabricatorProjectTransaction::TYPE_PARENT: 91 97 case PhabricatorProjectTransaction::TYPE_MILESTONE: 98 + case PhabricatorProjectTransaction::TYPE_DEFAULT_SORT: 99 + case PhabricatorProjectTransaction::TYPE_DEFAULT_FILTER: 92 100 return $xaction->getNewValue(); 93 101 case PhabricatorProjectTransaction::TYPE_HASWORKBOARD: 94 102 return (int)$xaction->getNewValue(); ··· 139 147 case PhabricatorProjectTransaction::TYPE_HASWORKBOARD: 140 148 $object->setHasWorkboard($xaction->getNewValue()); 141 149 return; 150 + case PhabricatorProjectTransaction::TYPE_DEFAULT_SORT: 151 + $object->setDefaultWorkboardSort($xaction->getNewValue()); 152 + return; 153 + case PhabricatorProjectTransaction::TYPE_DEFAULT_FILTER: 154 + $object->setDefaultWorkboardFilter($xaction->getNewValue()); 155 + return; 142 156 } 143 157 144 158 return parent::applyCustomInternalTransaction($object, $xaction); ··· 181 195 case PhabricatorProjectTransaction::TYPE_PARENT: 182 196 case PhabricatorProjectTransaction::TYPE_MILESTONE: 183 197 case PhabricatorProjectTransaction::TYPE_HASWORKBOARD: 198 + case PhabricatorProjectTransaction::TYPE_DEFAULT_SORT: 199 + case PhabricatorProjectTransaction::TYPE_DEFAULT_FILTER: 184 200 return; 185 201 } 186 202 ··· 866 882 PhabricatorLiskDAO $object, 867 883 array $xactions) { 868 884 885 + // Herald rules may run on behalf of other users and need to execute 886 + // membership checks against ancestors. 887 + $project = id(new PhabricatorProjectQuery()) 888 + ->setViewer(PhabricatorUser::getOmnipotentUser()) 889 + ->withPHIDs(array($object->getPHID())) 890 + ->needAncestorMembers(true) 891 + ->executeOne(); 892 + 869 893 return id(new PhabricatorProjectHeraldAdapter()) 870 - ->setProject($object); 894 + ->setProject($project); 871 895 } 872 896 873 897 }
+30
src/applications/project/storage/PhabricatorProject.php
··· 36 36 protected $projectDepth; 37 37 protected $projectPathKey; 38 38 39 + protected $properties = array(); 40 + 39 41 private $memberPHIDs = self::ATTACHABLE; 40 42 private $watcherPHIDs = self::ATTACHABLE; 41 43 private $sparseWatchers = self::ATTACHABLE; ··· 198 200 protected function getConfiguration() { 199 201 return array( 200 202 self::CONFIG_AUX_PHID => true, 203 + self::CONFIG_SERIALIZATION => array( 204 + 'properties' => self::SERIALIZATION_JSON, 205 + ), 201 206 self::CONFIG_COLUMN_SCHEMA => array( 202 207 'name' => 'sort128', 203 208 'status' => 'text32', ··· 547 552 ); 548 553 549 554 return idx($map, $color, $color); 555 + } 556 + 557 + public function getProperty($key, $default = null) { 558 + return idx($this->properties, $key, $default); 559 + } 560 + 561 + public function setProperty($key, $value) { 562 + $this->properties[$key] = $value; 563 + return $this; 564 + } 565 + 566 + public function getDefaultWorkboardSort() { 567 + return $this->getProperty('workboard.sort.default'); 568 + } 569 + 570 + public function setDefaultWorkboardSort($sort) { 571 + return $this->setProperty('workboard.sort.default', $sort); 572 + } 573 + 574 + public function getDefaultWorkboardFilter() { 575 + return $this->getProperty('workboard.filter.default'); 576 + } 577 + 578 + public function setDefaultWorkboardFilter($filter) { 579 + return $this->setProperty('workboard.filter.default', $filter); 550 580 } 551 581 552 582
+34 -14
src/applications/project/storage/PhabricatorProjectTransaction.php
··· 13 13 const TYPE_PARENT = 'project:parent'; 14 14 const TYPE_MILESTONE = 'project:milestone'; 15 15 const TYPE_HASWORKBOARD = 'project:hasworkboard'; 16 + const TYPE_DEFAULT_SORT = 'project:sort'; 17 + const TYPE_DEFAULT_FILTER = 'project:filter'; 16 18 17 19 // NOTE: This is deprecated, members are just a normal edge now. 18 20 const TYPE_MEMBERS = 'project:members'; ··· 66 68 return parent::getColor(); 67 69 } 68 70 69 - public function getIcon() { 71 + public function shouldHideForFeed() { 72 + switch ($this->getTransactionType()) { 73 + case self::TYPE_HASWORKBOARD: 74 + case self::TYPE_DEFAULT_SORT: 75 + case self::TYPE_DEFAULT_FILTER: 76 + return true; 77 + } 78 + 79 + return parent::shouldHideForFeed(); 80 + } 81 + 82 + public function shouldHideForMail(array $xactions) { 83 + switch ($this->getTransactionType()) { 84 + case self::TYPE_HASWORKBOARD: 85 + case self::TYPE_DEFAULT_SORT: 86 + case self::TYPE_DEFAULT_FILTER: 87 + return true; 88 + } 89 + 90 + return parent::shouldHideForMail($xactions); 91 + } 70 92 93 + public function getIcon() { 71 94 $old = $this->getOldValue(); 72 95 $new = $this->getNewValue(); 73 96 ··· 258 281 '%s disabled the workboard for this project.', 259 282 $author_handle); 260 283 } 284 + 285 + case self::TYPE_DEFAULT_SORT: 286 + return pht( 287 + '%s changed the default sort order for the project workboard.', 288 + $author_handle); 289 + 290 + case self::TYPE_DEFAULT_FILTER: 291 + return pht( 292 + '%s changed the default filter for the project workboard.', 293 + $author_handle); 261 294 } 262 295 263 296 return parent::getTitle(); ··· 377 410 count($rem), 378 411 $object_handle, 379 412 $this->renderSlugList($rem)); 380 - } 381 - 382 - case self::TYPE_HASWORKBOARD: 383 - if ($new) { 384 - return pht( 385 - '%s enabled the workboard for %s.', 386 - $author_handle, 387 - $object_handle); 388 - } else { 389 - return pht( 390 - '%s disabled the workboard for %s.', 391 - $author_handle, 392 - $object_handle); 393 413 } 394 414 395 415 }