From 90a0459821ea3c7aea270a98cccfba5908e8c430 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 2 Feb 2016 09:53:18 -0800 Subject: [PATCH] Roughly implement milestone columns on workboards Summary: Ref T10010. These aren't perfect but I think (?) they aren't horribly broken. - When a project is a parent project, destroy (as far as the user can tell) any custom columns. - When a project has milestones, automatically generate columns on the project's workboard (if it has a workboard). - When you move tasks between milestones, add the proper milestone tag. - When you move tasks out of milestones back into the backlog, add the proper parent project tag. - (Plenty of UI / design stuff to adjust.) Test Plan: - Dragged stuff between milestone columns. - Used a normal workboard. - Wasn't able to find any egregiously bad cases that did anything terrible. {F1088224} Reviewers: chad Reviewed By: chad Maniphest Tasks: T10010 Differential Revision: https://secure.phabricator.com/D15171 --- resources/builtin/image-200x200.png | Bin 0 -> 1261 bytes resources/celerity/map.php | 22 +- .../autopatches/20160202.board.1.proxy.sql | 2 + src/__phutil_library_map__.php | 2 + .../maniphest/editor/ManiphestEditEngine.php | 19 +- .../editor/ManiphestTransactionEditor.php | 14 +- .../PhabricatorProjectCoreTestCase.php | 57 ++++- .../PhabricatorProjectBoardViewController.php | 39 +++- .../PhabricatorProjectMoveController.php | 42 +++- ...atorProjectSubprojectWarningController.php | 2 +- .../engine/PhabricatorBoardLayoutEngine.php | 204 ++++++++++++++++-- .../PhabricatorColumnProxyInterface.php | 7 + .../query/PhabricatorProjectColumnQuery.php | 62 ++++++ .../project/storage/PhabricatorProject.php | 24 ++- .../storage/PhabricatorProjectColumn.php | 37 +++- src/docs/user/userguide/projects.diviner | 9 - src/view/phui/PHUIWorkpanelView.php | 4 +- .../projects/behavior-project-boards.js | 7 +- 18 files changed, 496 insertions(+), 57 deletions(-) create mode 100644 resources/builtin/image-200x200.png create mode 100644 resources/sql/autopatches/20160202.board.1.proxy.sql create mode 100644 src/applications/project/interface/PhabricatorColumnProxyInterface.php diff --git a/resources/builtin/image-200x200.png b/resources/builtin/image-200x200.png new file mode 100644 index 0000000000000000000000000000000000000000..53bc1e785c395c4c5882c1aec3bfc9eac13d4159 GIT binary patch literal 1261 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)K$^k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+So%F(978H@y}9l0EnO*b;NvP`ZAXzFZmwIQH=1vJekW)1 zev>-?KEC&_E>7OppL)Hy>Z@!AyLgUw=I#s5N9`4Qm6|R}{eR}w}If+ExGS@ z=H})f?G`Uy{PD-r?hPwUC$^pa^{eXewo8oPyXUE?sa-mFzi7{fsAq1=(kd!`>{2@_ zIC1G;{(Uv+>CdD2gu}GkrcU3oe}8>RiOB5Q+FEC4=b!oSC$-JlP;;jsJwj0|T3uZ| z*7T!_Tj1XpyS#lY5;<2FMhLpIgy>CA-aECZQ@y>toj+{qB%x^=-tC^V^lJIOOJ0jt z+s+*X=n{!AGf#ivszff#c7?YYufD# zJvM6{l9HBw{qm*d+D`S2lGee|A9w8BIrFNFjLebHPuHYQv-7^=lJ;NYp}sET@#Dva z>n@$!W|e<`UoZ2euZCvHvGxWA57q@l@UPz(6!Eol$={DVcJ11=cdzXBuAkHGrKP2- ztE+>9gMS+OE%r`)y!+6fG=u5yEC2til?wm4%g(Fn`@2}HW5;>b!X6iv{(q`d7p)

A!*r2F`cXrOC8Pk)l%RG%2HdoS4J>-UDQX=!P{e*aE(t&bD-PcklZ^U*ZzE*Dg}eKKkbkFz zKh86oK7D#}-qY^2KydP;=kmY^-a}p&?-aXs{{_mtoZL|{iO>7^@oe3DtyeA9NZ4G> z`ugwiqUF=|FCBPRVxNnl(00^Y2~Oy7xlpTJG2EWyjCjJP(N2-dyqP`0Ra#BG(>4J`SVZxGn>?;qc^mtdbLg4Fi(_guKoW{uRne)3>Lc^IqmD$sq^gL z3x%!L7VFlJ`(t5YabwNXsF11kZbP0l+XkKkFP_? literal 0 HcmV?d00001 diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 69819d4bd9..efe626ca21 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -413,7 +413,7 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => '2290aeef', 'rsrc/js/application/policy/behavior-policy-control.js' => 'ae45872f', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '5e9f347c', - 'rsrc/js/application/projects/behavior-project-boards.js' => 'c05fb42a', + 'rsrc/js/application/projects/behavior-project-boards.js' => '48470f95', 'rsrc/js/application/projects/behavior-project-create.js' => '065227cc', 'rsrc/js/application/projects/behavior-reorder-columns.js' => 'e1d25dfb', 'rsrc/js/application/releeph/releeph-preview-branch.js' => 'b2b4fbaf', @@ -653,7 +653,7 @@ return array( 'javelin-behavior-phui-profile-menu' => '12884df9', 'javelin-behavior-policy-control' => 'ae45872f', 'javelin-behavior-policy-rule-editor' => '5e9f347c', - 'javelin-behavior-project-boards' => 'c05fb42a', + 'javelin-behavior-project-boards' => '48470f95', 'javelin-behavior-project-create' => '065227cc', 'javelin-behavior-quicksand-blacklist' => '7927a7d3', 'javelin-behavior-recurring-edit' => '5f1c4d5f', @@ -1151,6 +1151,15 @@ return array( 'javelin-dom', 'javelin-workflow', ), + '48470f95' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'javelin-vector', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + ), '49b73b36' => array( 'javelin-behavior', 'javelin-dom', @@ -1779,15 +1788,6 @@ return array( 'javelin-install', 'javelin-dom', ), - 'c05fb42a' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-util', - 'javelin-vector', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - ), 'c1700f6f' => array( 'javelin-install', 'javelin-util', diff --git a/resources/sql/autopatches/20160202.board.1.proxy.sql b/resources/sql/autopatches/20160202.board.1.proxy.sql new file mode 100644 index 0000000000..a3e5965f26 --- /dev/null +++ b/resources/sql/autopatches/20160202.board.1.proxy.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_project.project_column + ADD proxyPHID VARBINARY(64); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index e499fcc68a..9910ad1349 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1889,6 +1889,7 @@ phutil_register_library_map(array( 'PhabricatorChatLogQuery' => 'applications/chatlog/query/PhabricatorChatLogQuery.php', 'PhabricatorChunkedFileStorageEngine' => 'applications/files/engine/PhabricatorChunkedFileStorageEngine.php', 'PhabricatorClusterConfigOptions' => 'applications/config/option/PhabricatorClusterConfigOptions.php', + 'PhabricatorColumnProxyInterface' => 'applications/project/interface/PhabricatorColumnProxyInterface.php', 'PhabricatorCommentEditEngineExtension' => 'applications/transactions/engineextension/PhabricatorCommentEditEngineExtension.php', 'PhabricatorCommentEditField' => 'applications/transactions/editfield/PhabricatorCommentEditField.php', 'PhabricatorCommentEditType' => 'applications/transactions/edittype/PhabricatorCommentEditType.php', @@ -7262,6 +7263,7 @@ phutil_register_library_map(array( 'PhabricatorDestructibleInterface', 'PhabricatorFulltextInterface', 'PhabricatorConduitResultInterface', + 'PhabricatorColumnProxyInterface', ), 'PhabricatorProjectAddHeraldAction' => 'PhabricatorProjectHeraldAction', 'PhabricatorProjectApplication' => 'PhabricatorApplication', diff --git a/src/applications/maniphest/editor/ManiphestEditEngine.php b/src/applications/maniphest/editor/ManiphestEditEngine.php index 6133838975..abbdd4c2e1 100644 --- a/src/applications/maniphest/editor/ManiphestEditEngine.php +++ b/src/applications/maniphest/editor/ManiphestEditEngine.php @@ -280,10 +280,23 @@ final class ManiphestEditEngine return new Aphront404Response(); } - // If the workboard's project has been removed from the card's project - // list, we are going to remove it from the board completely. + // If the workboard's project and all descendant projects have been removed + // from the card's project list, we are going to remove it from the board + // completely. + + // TODO: If the user did something sneaky and changed a subproject, we'll + // currently leave the card where it was but should really move it to the + // proper new column. + + $descendant_projects = id(new PhabricatorProjectQuery()) + ->setViewer($viewer) + ->withAncestorProjectPHIDs(array($column->getProjectPHID())) + ->execute(); + $board_phids = mpull($descendant_projects, 'getPHID', 'getPHID'); + $board_phids[$column->getProjectPHID()] = $column->getProjectPHID(); + $project_map = array_fuse($task->getProjectPHIDs()); - $remove_card = empty($project_map[$column->getProjectPHID()]); + $remove_card = !array_intersect_key($board_phids, $project_map); $positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($viewer) diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index bc8b5d3d31..46e2a617e8 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -222,12 +222,22 @@ final class ManiphestTransactionEditor // can't see. $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); + $select_phids = array($board_phid); + + $descendants = id(new PhabricatorProjectQuery()) + ->setViewer($omnipotent_viewer) + ->withAncestorProjectPHIDs($select_phids) + ->execute(); + foreach ($descendants as $descendant) { + $select_phids[] = $descendant->getPHID(); + } + $board_tasks = id(new ManiphestTaskQuery()) ->setViewer($omnipotent_viewer) ->withEdgeLogicPHIDs( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, - PhabricatorQueryConstraint::OPERATOR_AND, - array($board_phid)) + PhabricatorQueryConstraint::OPERATOR_ANCESTOR, + array($select_phids)) ->execute(); $object_phids = mpull($board_tasks, 'getPHID'); diff --git a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php index 74f637f16d..ee90afcb3b 100644 --- a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php +++ b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php @@ -972,7 +972,45 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $task1->getPHID(), ); $this->assertTasksInColumn($expect, $user, $board, $column); + } + public function testMilestoneMoves() { + $user = $this->createUser(); + $user->save(); + + $board = $this->createProject($user); + + $backlog = $this->addColumn($user, $board, 0); + + // Create a task into the backlog. + $task = $this->newTask($user, array($board)); + $expect = array( + $backlog->getPHID(), + ); + $this->assertColumns($expect, $user, $board, $task); + + $milestone = $this->createProject($user, $board, true); + + $this->addProjectTags($user, $task, array($milestone->getPHID())); + + // We just want the side effect of looking at the board: creation of the + // milestone column. + $this->loadColumns($user, $board, $task); + + $column = id(new PhabricatorProjectColumnQuery()) + ->setViewer($user) + ->withProjectPHIDs(array($board->getPHID())) + ->withProxyPHIDs(array($milestone->getPHID())) + ->executeOne(); + + $this->assertTrue((bool)$column); + + // Moving the task to the milestone should have moved it to the milestone + // column. + $expect = array( + $column->getPHID(), + ); + $this->assertColumns($expect, $user, $board, $task); } private function moveToColumn( @@ -1014,7 +1052,14 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { PhabricatorUser $viewer, PhabricatorProject $board, ManiphestTask $task) { + $column_phids = $this->loadColumns($viewer, $board, $task); + $this->assertEqual($expect, $column_phids); + } + private function loadColumns( + PhabricatorUser $viewer, + PhabricatorProject $board, + ManiphestTask $task) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($board->getPHID())) @@ -1028,7 +1073,7 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $column_phids = mpull($columns, 'getPHID'); $column_phids = array_values($column_phids); - $this->assertEqual($expect, $column_phids); + return $column_phids; } private function assertTasksInColumn( @@ -1236,6 +1281,16 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $this->applyTransactions($project, $user, $xactions); + // Force these values immediately; they are normally updated by the + // index engine. + if ($parent) { + if ($is_milestone) { + $parent->setHasMilestones(1)->save(); + } else { + $parent->setHasSubprojects(1)->save(); + } + } + return $project; } diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index d2fdf8d763..b046707c33 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -95,17 +95,27 @@ final class PhabricatorProjectBoardViewController $task_query = $search_engine->buildQueryFromSavedQuery($saved); + $select_phids = array($project->getPHID()); + if ($project->getHasSubprojects() || $project->getHasMilestones()) { + $descendants = id(new PhabricatorProjectQuery()) + ->setViewer($viewer) + ->withAncestorProjectPHIDs($select_phids) + ->execute(); + foreach ($descendants as $descendant) { + $select_phids[] = $descendant->getPHID(); + } + } + $tasks = $task_query ->withEdgeLogicPHIDs( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, - PhabricatorQueryConstraint::OPERATOR_AND, - array($project->getPHID())) + PhabricatorQueryConstraint::OPERATOR_ANCESTOR, + array($select_phids)) ->setOrder(ManiphestTaskQuery::ORDER_PRIORITY) ->setViewer($viewer) ->execute(); $tasks = mpull($tasks, null, 'getPHID'); - $board_phid = $project->getPHID(); $layout_engine = id(new PhabricatorBoardLayoutEngine()) @@ -225,6 +235,13 @@ final class PhabricatorProjectBoardViewController } } + $proxy = $column->getProxy(); + if ($proxy && !$proxy->isMilestone()) { + // TODO: For now, don't show subproject columns because we can't + // handle tasks with multiple positions yet. + continue; + } + $task_phids = $layout_engine->getColumnObjectPHIDs( $board_phid, $column->getPHID()); @@ -247,6 +264,11 @@ final class PhabricatorProjectBoardViewController $panel->setHeaderIcon($header_icon); } + $display_class = $column->getDisplayClass(); + if ($display_class) { + $panel->addClass($display_class); + } + if ($column->isHidden()) { $panel->addClass('project-panel-hidden'); } @@ -582,6 +604,12 @@ final class PhabricatorProjectBoardViewController $column_items = array(); + if ($column->getProxyPHID()) { + $default_phid = $column->getProxyPHID(); + } else { + $default_phid = $column->getProjectPHID(); + } + $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-plus') ->setName(pht('Create Task...')) @@ -590,6 +618,7 @@ final class PhabricatorProjectBoardViewController ->setMetadata( array( 'columnPHID' => $column->getPHID(), + 'projectPHID' => $default_phid, )); $batch_edit_uri = $request->getRequestURI(); @@ -738,6 +767,10 @@ final class PhabricatorProjectBoardViewController } } + // TODO: Tailor this UI if the project is already a parent project. We + // should not offer options for creating a parent project workboard, since + // they can't have their own columns. + $new_selector = id(new AphrontFormRadioButtonControl()) ->setLabel(pht('Columns')) ->setName('initialize-type') diff --git a/src/applications/project/controller/PhabricatorProjectMoveController.php b/src/applications/project/controller/PhabricatorProjectMoveController.php index 3695ad21ac..e9d8c8be78 100644 --- a/src/applications/project/controller/PhabricatorProjectMoveController.php +++ b/src/applications/project/controller/PhabricatorProjectMoveController.php @@ -139,7 +139,33 @@ final class PhabricatorProjectMoveController ->setTransactionType(ManiphestTransaction::TYPE_SUBPRIORITY) ->setNewValue($sub); } - } + } + + $proxy = $column->getProxy(); + if ($proxy) { + // We're moving the task into a subproject or milestone column, so add + // the subproject or milestone. + $add_projects = array($proxy->getPHID()); + } else if ($project->getHasSubprojects() || $project->getHasMilestones()) { + // We're moving the task into the "Backlog" column on the parent project, + // so add the parent explicitly. This gets rid of any subproject or + // milestone tags. + $add_projects = array($project->getPHID()); + } else { + $add_projects = array(); + } + + if ($add_projects) { + $project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; + + $xactions[] = id(new ManiphestTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) + ->setMetadataValue('edge:type', $project_type) + ->setNewValue( + array( + '+' => array_fuse($add_projects), + )); + } $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) @@ -157,6 +183,18 @@ final class PhabricatorProjectMoveController ->executeOne(); } + // Reload the object so it reflects edits which have been applied. + $object = id(new ManiphestTaskQuery()) + ->setViewer($viewer) + ->withPHIDs(array($object_phid)) + ->needProjectPHIDs(true) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + $card = id(new ProjectBoardTaskCard()) ->setViewer($viewer) ->setTask($object) @@ -169,6 +207,6 @@ final class PhabricatorProjectMoveController return id(new AphrontAjaxResponse())->setContent( array('task' => $card)); - } + } } diff --git a/src/applications/project/controller/PhabricatorProjectSubprojectWarningController.php b/src/applications/project/controller/PhabricatorProjectSubprojectWarningController.php index 9e089f138c..d9dd401101 100644 --- a/src/applications/project/controller/PhabricatorProjectSubprojectWarningController.php +++ b/src/applications/project/controller/PhabricatorProjectSubprojectWarningController.php @@ -35,7 +35,7 @@ final class PhabricatorProjectSubprojectWarningController $conversion_help = pht( "Creating a project's first subproject **moves all ". - "members** and **destroys all workboard columns**.". + "members** to become members of the subproject instead". "\n\n". "See [[ %s | Projects User Guide ]] in the documentation for details. ". "This process can not be undone.", diff --git a/src/applications/project/engine/PhabricatorBoardLayoutEngine.php b/src/applications/project/engine/PhabricatorBoardLayoutEngine.php index e578131c83..31be95816b 100644 --- a/src/applications/project/engine/PhabricatorBoardLayoutEngine.php +++ b/src/applications/project/engine/PhabricatorBoardLayoutEngine.php @@ -320,8 +320,63 @@ final class PhabricatorBoardLayoutEngine extends Phobject { $columns = msort($columns, 'getSequence'); $columns = mpull($columns, null, 'getPHID'); - $this->columnMap = $columns; + $need_children = array(); + foreach ($boards as $phid => $board) { + if ($board->getHasMilestones() || $board->getHasSubprojects()) { + $need_children[] = $phid; + } + } + + if ($need_children) { + $children = id(new PhabricatorProjectQuery()) + ->setViewer($viewer) + ->withParentProjectPHIDs($need_children) + ->execute(); + $children = mpull($children, null, 'getPHID'); + $children = mgroup($children, 'getParentProjectPHID'); + } else { + $children = array(); + } + $columns = mgroup($columns, 'getProjectPHID'); + foreach ($boards as $board_phid => $board) { + $board_columns = idx($columns, $board_phid, array()); + + // If the project has milestones, create any missing columns. + if ($board->getHasMilestones() || $board->getHasSubprojects()) { + $child_projects = idx($children, $board_phid, array()); + + $next_sequence = last($board_columns)->getSequence() + 1; + $proxy_columns = mpull($board_columns, null, 'getProxyPHID'); + foreach ($child_projects as $child_phid => $child) { + if (isset($proxy_columns[$child_phid])) { + continue; + } + + $new_column = PhabricatorProjectColumn::initializeNewColumn($viewer) + ->attachProject($board) + ->attachProxy($child) + ->setSequence($next_sequence++) + ->setProjectPHID($board_phid) + ->setProxyPHID($child_phid); + + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + $new_column->save(); + unset($unguarded); + + $board_columns[$new_column->getPHID()] = $new_column; + } + } + + $columns[$board_phid] = $board_columns; + } + + foreach ($columns as $board_phid => $board_columns) { + foreach ($board_columns as $board_column) { + $column_phid = $board_column->getPHID(); + $this->columnMap[$column_phid] = $board_column; + } + } return $columns; } @@ -350,6 +405,8 @@ final class PhabricatorBoardLayoutEngine extends Phobject { array $columns, array $positions) { + $viewer = $this->getViewer(); + $board_phid = $board->getPHID(); $position_groups = mgroup($positions, 'getObjectPHID'); @@ -363,32 +420,143 @@ final class PhabricatorBoardLayoutEngine extends Phobject { } } + // Find all the columns which are proxies for other objects. + $proxy_map = array(); + foreach ($columns as $column) { + $proxy_phid = $column->getProxyPHID(); + if ($proxy_phid) { + $proxy_map[$proxy_phid] = $column->getPHID(); + } + } + $object_phids = $this->getObjectPHIDs(); + + // If we have proxies, we need to force cards into the correct proxy + // columns. + if ($proxy_map) { + $edge_query = id(new PhabricatorEdgeQuery()) + ->withSourcePHIDs($object_phids) + ->withEdgeTypes( + array( + PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, + )); + $edge_query->execute(); + + $project_phids = $edge_query->getDestinationPHIDs(); + $project_phids = array_fuse($project_phids); + } else { + $project_phids = array(); + } + + if ($project_phids) { + $projects = id(new PhabricatorProjectQuery()) + ->setViewer($viewer) + ->withPHIDs($project_phids) + ->execute(); + $projects = mpull($projects, null, 'getPHID'); + } else { + $projects = array(); + } + + // Build a map from every project that any task is tagged with to the + // ancestor project which has a column on this board, if one exists. + $ancestor_map = array(); + foreach ($projects as $phid => $project) { + if (isset($proxy_map[$phid])) { + $ancestor_map[$phid] = $proxy_map[$phid]; + } else { + $seen = array($phid); + foreach ($project->getAncestorProjects() as $ancestor) { + $ancestor_phid = $ancestor->getPHID(); + $seen[] = $ancestor_phid; + if (isset($proxy_map[$ancestor_phid])) { + foreach ($seen as $project_phid) { + $ancestor_map[$project_phid] = $proxy_map[$ancestor_phid]; + } + } + } + } + } + foreach ($object_phids as $object_phid) { $positions = idx($position_groups, $object_phid, array()); - // Remove any positions in columns which no longer exist. - foreach ($positions as $key => $position) { - $column_phid = $position->getColumnPHID(); - if (empty($columns[$column_phid])) { - $this->remQueue[] = $position; - unset($positions[$key]); + // First, check for objects that have corresponding proxy columns. We're + // going to overwrite normal column positions if a tag belongs to a proxy + // column, since you can't be in normal columns if you're in proxy + // columns. + $proxy_hits = array(); + if ($proxy_map) { + $object_project_phids = $edge_query->getDestinationPHIDs( + array( + $object_phid, + )); + + foreach ($object_project_phids as $project_phid) { + if (isset($ancestor_map[$project_phid])) { + $proxy_hits[] = $ancestor_map[$project_phid]; + } } } - // If the object has no position, put it on the default column. - if (!$positions) { - $new_position = id(new PhabricatorProjectColumnPosition()) - ->setBoardPHID($board_phid) - ->setColumnPHID($default_phid) - ->setObjectPHID($object_phid) - ->setSequence(0); + if ($proxy_hits) { + // TODO: For now, only one column hit is permissible. + $proxy_hits = array_slice($proxy_hits, 0, 1); - $this->addQueue[] = $new_position; + $proxy_hits = array_fuse($proxy_hits); - $positions = array( - $new_position, - ); + // Check the object positions: we hope to find a position in each + // column the object should be part of. We're going to drop any + // invalid positions and create new positions where positions are + // missing. + foreach ($positions as $key => $position) { + $column_phid = $position->getColumnPHID(); + if (isset($proxy_hits[$column_phid])) { + // Valid column, mark the position as found. + unset($proxy_hits[$column_phid]); + } else { + // Invalid column, ignore the position. + unset($positions[$key]); + } + } + + // Create new positions for anything we haven't found. + foreach ($proxy_hits as $proxy_hit) { + $new_position = id(new PhabricatorProjectColumnPosition()) + ->setBoardPHID($board_phid) + ->setColumnPHID($proxy_hit) + ->setObjectPHID($object_phid) + ->setSequence(0); + + $this->addQueue[] = $new_position; + + $positions[] = $new_position; + } + } else { + // Ignore any positions in columns which no longer exist. We don't + // actively destory them because the rest of the code ignores them and + // there's no real need to destroy the data. + foreach ($positions as $key => $position) { + $column_phid = $position->getColumnPHID(); + if (empty($columns[$column_phid])) { + unset($positions[$key]); + } + } + + // If the object has no position, put it on the default column. + if (!$positions) { + $new_position = id(new PhabricatorProjectColumnPosition()) + ->setBoardPHID($board_phid) + ->setColumnPHID($default_phid) + ->setObjectPHID($object_phid) + ->setSequence(0); + + $this->addQueue[] = $new_position; + + $positions = array( + $new_position, + ); + } } foreach ($positions as $position) { diff --git a/src/applications/project/interface/PhabricatorColumnProxyInterface.php b/src/applications/project/interface/PhabricatorColumnProxyInterface.php new file mode 100644 index 0000000000..4e3c882d35 --- /dev/null +++ b/src/applications/project/interface/PhabricatorColumnProxyInterface.php @@ -0,0 +1,7 @@ +proxyPHIDs = $proxy_phids; + return $this; + } + public function withStatuses(array $status) { $this->statuses = $status; return $this; @@ -60,6 +66,55 @@ final class PhabricatorProjectColumnQuery $column->attachProject($project); } + $proxy_phids = array_filter(mpull($page, 'getProjectPHID')); + + return $page; + } + + protected function didFilterPage(array $page) { + $proxy_phids = array(); + foreach ($page as $column) { + $proxy_phid = $column->getProxyPHID(); + if ($proxy_phid !== null) { + $proxy_phids[$proxy_phid] = $proxy_phid; + } + } + + if ($proxy_phids) { + $proxies = id(new PhabricatorObjectQuery()) + ->setParentQuery($this) + ->setViewer($this->getViewer()) + ->withPHIDs($proxy_phids) + ->execute(); + $proxies = mpull($proxies, null, 'getPHID'); + } else { + $proxies = array(); + } + + foreach ($page as $key => $column) { + $proxy_phid = $column->getProxyPHID(); + + if ($proxy_phid !== null) { + $proxy = idx($proxies, $proxy_phid); + + // Only attach valid proxies, so we don't end up getting surprsied if + // an install somehow gets junk into their database. + if (!($proxy instanceof PhabricatorColumnProxyInterface)) { + $proxy = null; + } + + if (!$proxy) { + $this->didRejectResult($column); + unset($page[$key]); + continue; + } + } else { + $proxy = null; + } + + $column->attachProxy($proxy); + } + return $page; } @@ -87,6 +142,13 @@ final class PhabricatorProjectColumnQuery $this->projectPHIDs); } + if ($this->proxyPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'proxyPHID IN (%Ls)', + $this->proxyPHIDs); + } + if ($this->statuses !== null) { $where[] = qsprintf( $conn, diff --git a/src/applications/project/storage/PhabricatorProject.php b/src/applications/project/storage/PhabricatorProject.php index 6cf116688e..b3d23a089e 100644 --- a/src/applications/project/storage/PhabricatorProject.php +++ b/src/applications/project/storage/PhabricatorProject.php @@ -9,7 +9,8 @@ final class PhabricatorProject extends PhabricatorProjectDAO PhabricatorCustomFieldInterface, PhabricatorDestructibleInterface, PhabricatorFulltextInterface, - PhabricatorConduitResultInterface { + PhabricatorConduitResultInterface, + PhabricatorColumnProxyInterface { protected $name; protected $status = PhabricatorProjectStatus::STATUS_ACTIVE; @@ -663,4 +664,25 @@ final class PhabricatorProject extends PhabricatorProjectDAO ); } + +/* -( PhabricatorColumnProxyInterface )------------------------------------ */ + + + public function getProxyColumnName() { + return $this->getName(); + } + + public function getProxyColumnIcon() { + return $this->getDisplayIconIcon(); + } + + public function getProxyColumnClass() { + if ($this->isMilestone()) { + return 'phui-workboard-column-milestone'; + } + + return null; + } + + } diff --git a/src/applications/project/storage/PhabricatorProjectColumn.php b/src/applications/project/storage/PhabricatorProjectColumn.php index ca124fec96..a63312438c 100644 --- a/src/applications/project/storage/PhabricatorProjectColumn.php +++ b/src/applications/project/storage/PhabricatorProjectColumn.php @@ -17,10 +17,12 @@ final class PhabricatorProjectColumn protected $name; protected $status; protected $projectPHID; + protected $proxyPHID; protected $sequence; protected $properties = array(); private $project = self::ATTACHABLE; + private $proxy = self::ATTACHABLE; public static function initializeNewColumn(PhabricatorUser $user) { return id(new PhabricatorProjectColumn()) @@ -38,6 +40,7 @@ final class PhabricatorProjectColumn 'name' => 'text255', 'status' => 'uint32', 'sequence' => 'uint32', + 'proxyPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_status' => array( @@ -46,6 +49,10 @@ final class PhabricatorProjectColumn 'key_sequence' => array( 'columns' => array('projectPHID', 'sequence'), ), + 'key_proxy' => array( + 'columns' => array('projectPHID', 'proxyPHID'), + 'unique' => true, + ), ), ) + parent::getConfiguration(); } @@ -64,6 +71,15 @@ final class PhabricatorProjectColumn return $this->assertAttached($this->project); } + public function attachProxy($proxy) { + $this->proxy = $proxy; + return $this; + } + + public function getProxy() { + return $this->assertAttached($this->proxy); + } + public function isDefaultColumn() { return (bool)$this->getProperty('isDefault'); } @@ -73,6 +89,11 @@ final class PhabricatorProjectColumn } public function getDisplayName() { + $proxy = $this->getProxy(); + if ($proxy) { + return $proxy->getProxyColumnName(); + } + $name = $this->getName(); if (strlen($name)) { return $name; @@ -96,11 +117,23 @@ final class PhabricatorProjectColumn return null; } + public function getDisplayClass() { + $proxy = $this->getProxy(); + if ($proxy) { + return $proxy->getProxyColumnClass(); + } + + return null; + } + public function getHeaderIcon() { - $icon = null; + $proxy = $this->getProxy(); + if ($proxy) { + return $proxy->getProxyColumnIcon(); + } if ($this->isHidden()) { - $icon = 'fa-eye-slash'; + return 'fa-eye-slash'; } return null; diff --git a/src/docs/user/userguide/projects.diviner b/src/docs/user/userguide/projects.diviner index 7d014f3fb7..6f29e3586c 100644 --- a/src/docs/user/userguide/projects.diviner +++ b/src/docs/user/userguide/projects.diviner @@ -162,7 +162,6 @@ subprojects, parent projects, and milestones. |---|---|---|---|---| | //Members// | Yes | Union of Subprojects | Yes | Same as Parent | | //Policies// | Yes | Yes | Affected by Parent | Same as Parent | -| //Workboard// | Yes | No Custom Columns | Yes | Yes | | //Hashtags// | Yes | Yes | Yes | Special | @@ -257,14 +256,6 @@ parent project is an ancestor of the new subproject. You can edit the project afterward to change or remove members if you want to split membership apart in a more granular way across multiple new subprojects. -**No Workboard Columns**: Parent projects can not have their own workboard -columns: instead, the workboard of a parent project shows columns representing -the child projects. - -Thus, a project's workboard columns are destroyed when you add the first -subproject. All objects on the workboard will be returned to the project's -backlog. The new board will show columns for subprojects instead. - **Searching**: When you search for a parent project, results for any subproject are returned. For example, if you search for {nav Engineering}, your query will match results in {nav Engineering} itself, but also subprojects like diff --git a/src/view/phui/PHUIWorkpanelView.php b/src/view/phui/PHUIWorkpanelView.php index 96978de952..50b2e12161 100644 --- a/src/view/phui/PHUIWorkpanelView.php +++ b/src/view/phui/PHUIWorkpanelView.php @@ -10,8 +10,8 @@ final class PHUIWorkpanelView extends AphrontTagView { private $headerTag; private $headerIcon; - public function setHeaderIcon(PHUIIconView $header_icon) { - $this->headerIcon = $header_icon; + public function setHeaderIcon($icon) { + $this->headerIcon = $icon; return $this; } diff --git a/webroot/rsrc/js/application/projects/behavior-project-boards.js b/webroot/rsrc/js/application/projects/behavior-project-boards.js index bd69642e1d..8d9b61cc8c 100644 --- a/webroot/rsrc/js/application/projects/behavior-project-boards.js +++ b/webroot/rsrc/js/application/projects/behavior-project-boards.js @@ -280,13 +280,16 @@ JX.behavior('project-boards', function(config, statics) { // close the dropdown, but don't want to follow the link. e.prevent(); - var column_phid = e.getNodeData('column-add-task').columnPHID; + var column_data = e.getNodeData('column-add-task'); + var column_phid = column_data.columnPHID; + var request_data = { responseType: 'card', columnPHID: column_phid, - projects: statics.projectPHID, + projects: column_data.projectPHID, order: statics.order }; + var cols = getcolumns(); var ii; var column;