diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2304,6 +2304,7 @@ 'PhabricatorProjectBoardReorderController' => 'applications/project/controller/PhabricatorProjectBoardReorderController.php', 'PhabricatorProjectBoardViewController' => 'applications/project/controller/PhabricatorProjectBoardViewController.php', 'PhabricatorProjectColumn' => 'applications/project/storage/PhabricatorProjectColumn.php', + 'PhabricatorProjectColumnContentController' => 'applications/project/controller/PhabricatorProjectColumnContentController.php', 'PhabricatorProjectColumnDetailController' => 'applications/project/controller/PhabricatorProjectColumnDetailController.php', 'PhabricatorProjectColumnEditController' => 'applications/project/controller/PhabricatorProjectColumnEditController.php', 'PhabricatorProjectColumnHideController' => 'applications/project/controller/PhabricatorProjectColumnHideController.php', @@ -5726,6 +5727,7 @@ 'PhabricatorPolicyInterface', 'PhabricatorDestructibleInterface', ), + 'PhabricatorProjectColumnContentController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnDetailController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnEditController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnHideController' => 'PhabricatorProjectBoardController', diff --git a/src/applications/project/application/PhabricatorProjectApplication.php b/src/applications/project/application/PhabricatorProjectApplication.php --- a/src/applications/project/application/PhabricatorProjectApplication.php +++ b/src/applications/project/application/PhabricatorProjectApplication.php @@ -74,12 +74,16 @@ => 'PhabricatorProjectColumnEditController', 'hide/(?:(?P\d+)/)?' => 'PhabricatorProjectColumnHideController', - 'column/(?:(?P\d+)/)?' + 'detail/(?:(?P\d+)/)?' => 'PhabricatorProjectColumnDetailController', 'import/' => 'PhabricatorProjectBoardImportController', 'reorder/' => 'PhabricatorProjectBoardReorderController', + 'column/(?:(?P\d+)/)'. + '(?Pfilter/)?'. + '(?:query/(?P[^/]+)/)?' + => 'PhabricatorProjectColumnContentController', ), 'update/(?P[1-9]\d*)/(?P[^/]+)/' => 'PhabricatorProjectUpdateController', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -286,6 +286,8 @@ $panel = id(new PHUIWorkpanelView()) ->setHeader($column->getDisplayName()) ->setSubHeader($column->getDisplayType()) + ->setHeaderURI($this->getApplicationURI( + 'board/'.$this->id.'/column/'.$column->getID().'/')) ->addSigil('workpanel'); $header_icon = $column->getHeaderIcon(); @@ -637,7 +639,7 @@ ->setDisabled(!$can_batch_edit); $edit_uri = $this->getApplicationURI( - 'board/'.$this->id.'/column/'.$column->getID().'/'); + 'board/'.$this->id.'/detail/'.$column->getID().'/'); $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-pencil') diff --git a/src/applications/project/controller/PhabricatorProjectColumnContentController.php b/src/applications/project/controller/PhabricatorProjectColumnContentController.php new file mode 100644 --- /dev/null +++ b/src/applications/project/controller/PhabricatorProjectColumnContentController.php @@ -0,0 +1,429 @@ +projectID = $data['projectID']; + $this->id = idx($data, 'id'); + $this->queryKey = idx($data, 'queryKey'); + $this->filter = (bool)idx($data, 'filter'); + } + + public function processRequest() { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + $project = id(new PhabricatorProjectQuery()) + ->setViewer($viewer) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + )) + ->withIDs(array($this->projectID)) + ->executeOne(); + + if (!$project) { + return new Aphront404Response(); + } + $this->setProject($project); + + $column = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withIDs(array($this->id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + )) + ->executeOne(); + if (!$column) { + return new Aphront404Response(); + } + + $sort_key = $request->getStr('order'); + switch ($sort_key) { + case PhabricatorProjectColumn::ORDER_NATURAL: + case PhabricatorProjectColumn::ORDER_PRIORITY: + break; + default: + $sort_key = PhabricatorProjectColumn::DEFAULT_ORDER; + break; + } + $this->sortKey = $sort_key; + + // We already know who we are, this is for the side nav later + $column_query = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withStatuses( + array(PhabricatorProjectColumn::STATUS_ACTIVE)) + ->withProjectPHIDs(array($project->getPHID())); + $columns = $column_query->execute(); + $columns = array_reverse(mpull($columns, null, 'getSequence')); + + $title = pht('%s', $column->getDisplayName()); + $crumbs = $this->buildApplicationCrumbs(); + $crumbs->addTextCrumb( + pht('Board'), + $this->getApplicationURI('board/'.$project->getID().'/')); + $crumbs->addTextCrumb($title); + + $column_uri = $this->getApplicationURI('board/'.$project->getID(). + '/column/'.$this->id.'/'); + // copy pasted from view starts + + $engine = id(new ManiphestTaskSearchEngine()) + ->setViewer($viewer) + ->setBaseURI($column_uri) + ->setIsBoardView(true); + + if ($request->isFormPost()) { + $saved = $engine->buildSavedQueryFromRequest($request); + $engine->saveQuery($saved); + return id(new AphrontRedirectResponse())->setURI( + $this->getURIWithState( + $engine->getQueryResultsPageURI($saved->getQueryKey()))); + } + + $query_key = $this->queryKey; + if (!$query_key) { + $query_key = 'open'; + } + $this->queryKey = $query_key; + + $custom_query = null; + if ($engine->isBuiltinQuery($query_key)) { + $saved = $engine->buildSavedQueryFromBuiltin($query_key); + $saved->setParameter('group', 'none'); + } else { + $saved = id(new PhabricatorSavedQueryQuery()) + ->setViewer($viewer) + ->withQueryKeys(array($query_key)) + ->executeOne(); + + if (!$saved) { + return new Aphront404Response(); + } + + $custom_query = $saved; + } + + if ($this->filter) { + $filter_form = id(new AphrontFormView()) + ->setUser($viewer); + $engine->buildSearchForm($filter_form, $saved); + + return $this->newDialog() + ->setWidth(AphrontDialogView::WIDTH_FULL) + ->setTitle(pht('Advanced Filter')) + ->appendChild($filter_form->buildLayoutView()) + ->setSubmitURI($column_uri) + ->addSubmitButton(pht('Apply Filter')) + ->addCancelButton($column_uri); + } + + $task_query = $engine->buildQueryFromSavedQuery($saved); + + $tasks = $task_query + ->withEdgeLogicPHIDs( + PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, + PhabricatorQueryConstraint::OPERATOR_AND, + array($project->getPHID())) + ->setOrderBy(ManiphestTaskQuery::ORDER_PRIORITY) + ->setGroupBy(ManiphestTaskQuery::GROUP_NONE) + ->setViewer($viewer) + ->execute(); + $tasks = mpull($tasks, null, 'getPHID'); + + if ($tasks) { + $positions = id(new PhabricatorProjectColumnPositionQuery()) + ->setViewer($viewer) + ->withObjectPHIDs(mpull($tasks, 'getPHID')) + ->withColumns(array($column)) + ->execute(); + $positions = mpull($positions, null, 'getObjectPHID'); + } else { + $positions = array(); + } + + $task_map = array(); + foreach ($tasks as $task) { + $task_phid = $task->getPHID(); + if (empty($positions[$task_phid])) { + // This shouldn't normally be possible because we create positions on + // demand, but we might have raced as an object was removed from the + // board. Just drop the task if we don't have a position for it. + continue; + } + + $position = $positions[$task_phid]; + $task_map[$position->getColumnPHID()][] = $task_phid; + } + + // We get all open tasks for the project (or whatever the query + // is) and then filter down to tasks for a specific column. This is + // potentially inefficient for large projects, but we aleady need to + // display everything on a the main workboard anyway so we should be okay + // here. + $tasks = array_select_keys($tasks, array_keys($positions)); + + // If we're showing the board in "natural" order, sort columns by their + // column positions. + if ($this->sortKey == PhabricatorProjectColumn::ORDER_NATURAL) { + foreach ($task_map as $column_phid => $task_phids) { + $order = array(); + foreach ($task_phids as $task_phid) { + if (isset($positions[$task_phid])) { + $order[$task_phid] = $positions[$task_phid]->getOrderingKey(); + } else { + $order[$task_phid] = 0; + } + } + asort($order); + $task_map[$column_phid] = array_keys($order); + } + } + + if (!empty($tasks)) { + $tasks = array_select_keys(mpull($tasks, null, 'getPHID'), + $task_map[$column->getPHID()]); + } + + $task_can_edit_map = id(new PhabricatorPolicyFilter()) + ->setViewer($viewer) + ->requireCapabilities(array(PhabricatorPolicyCapability::CAN_EDIT)) + ->apply($tasks); + + $results_view = id(new ManiphestTaskResultListView()) + ->setUser($viewer) + ->setTasks($tasks) + ->setSavedQuery($saved) + ->setCanBatchEdit(true) + ->setCanEditPriority(true) + ->setShowBatchControls(true); + + $sort_menu = $this->buildSortMenu( + $viewer, + $sort_key); + + $filter_menu = $this->buildFilterMenu( + $viewer, + $custom_query, + $engine, + $query_key); + + + $header = id(new PHUIHeaderView()) + // FIXME: PHUIHeaderView with only ActionLinks cuts off menus + ->setHeader('dummy') + ->setNoBackground(true) + ->addActionLink($sort_menu) + ->addActionLink($filter_menu); + + $nav = $this->buildSideNav($this->getApplicationURI('board/'. + $project->getID(). + '/column'), + $columns); + + $nav->appendChild( + array( + $crumbs, + $header, + $results_view->render(), + )); + + return $this->buildApplicationPage( + $nav, + array( + 'title' => $title, + )); + } + + + private function buildSideNav($base_uri, array $columns) { + $nav = new AphrontSideNavFilterView(); + + $nav->setBaseURI(new PhutilURI($base_uri.'/')) + ->addLabel('Columns'); + + foreach ($columns as $column) { + $nav->addFilter($column->getID(), $column->getDisplayName()); + } + $valid_filter = $nav->selectFilter($this->id); + + return $nav; + } + + + + // FIXME: pure copy-paste from PhabricatorProjectBoardViewController + + private function buildSortMenu( + PhabricatorUser $viewer, + $sort_key) { + + $sort_icon = id(new PHUIIconView()) + ->setIconFont('fa-sort-amount-asc bluegrey'); + + $named = array( + PhabricatorProjectColumn::ORDER_NATURAL => pht('Natural'), + PhabricatorProjectColumn::ORDER_PRIORITY => pht('Sort by Priority'), + ); + + $base_uri = $this->getURIWithState(); + + $items = array(); + foreach ($named as $key => $name) { + $is_selected = ($key == $sort_key); + if ($is_selected) { + $active_order = $name; + } + + $item = id(new PhabricatorActionView()) + ->setIcon('fa-sort-amount-asc') + ->setSelected($is_selected) + ->setName($name); + + $uri = $base_uri->alter('order', $key); + $item->setHref($uri); + + $items[] = $item; + } + + $sort_menu = id(new PhabricatorActionListView()) + ->setUser($viewer); + foreach ($items as $item) { + $sort_menu->addAction($item); + } + + $sort_button = id(new PHUIButtonView()) + ->setText(pht('Sort: %s', $active_order)) + ->setIcon($sort_icon) + ->setTag('a') + ->setHref('#') + ->addSigil('boards-dropdown-menu') + ->setMetadata( + array( + 'items' => hsprintf('%s', $sort_menu), + )); + + return $sort_button; + } + + private function buildFilterMenu( + PhabricatorUser $viewer, + $custom_query, + PhabricatorApplicationSearchEngine $engine, + $query_key) { + + $filter_icon = id(new PHUIIconView()) + ->setIconFont('fa-search-plus bluegrey'); + + $named = array( + 'open' => pht('Open Tasks'), + 'all' => pht('All Tasks'), + ); + + if ($viewer->isLoggedIn()) { + $named['assigned'] = pht('Assigned to Me'); + } + + if ($custom_query) { + $named[$custom_query->getQueryKey()] = pht('Custom Filter'); + } + + $items = array(); + foreach ($named as $key => $name) { + $is_selected = ($key == $query_key); + if ($is_selected) { + $active_filter = $name; + } + + $is_custom = false; + if ($custom_query) { + $is_custom = ($key == $custom_query->getQueryKey()); + } + + $item = id(new PhabricatorActionView()) + ->setIcon('fa-search') + ->setSelected($is_selected) + ->setName($name); + + if ($is_custom) { + $uri = $this->getApplicationURI('board/'.$this->projectID. + '/column/'.$this->id.'/filter/query/'.$key.'/'); + $item->setWorkflow(true); + } else { + $uri = $engine->getQueryResultsPageURI($key); + } + + $uri = $this->getURIWithState($uri); + $item->setHref($uri); + + $items[] = $item; + } + + $items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cog') + ->setHref($this->getApplicationURI('board/'.$this->projectID. + '/column/'.$this->id.'/filter/')) // mod + ->setWorkflow(true) + ->setName(pht('Advanced Filter...')); + + $filter_menu = id(new PhabricatorActionListView()) + ->setUser($viewer); + foreach ($items as $item) { + $filter_menu->addAction($item); + } + + $filter_button = id(new PHUIButtonView()) + ->setText(pht('Filter: %s', $active_filter)) + ->setIcon($filter_icon) + ->setTag('a') + ->setHref('#') + ->addSigil('boards-dropdown-menu') + ->setMetadata( + array( + 'items' => hsprintf('%s', $filter_menu), + )); + + return $filter_button; + } + + + /** + * Add current state parameters (like order and the visibility of hidden + * columns) to a URI. + * + * This allows actions which toggle or adjust one piece of state to keep + * the rest of the board state persistent. If no URI is provided, this method + * starts with the request URI. + * + * @param string|null URI to add state parameters to. + * @return PhutilURI URI with state parameters. + */ + private function getURIWithState($base = null) { + if ($base === null) { + $base = $this->getRequest()->getRequestURI(); + } + + $base = new PhutilURI($base); + + if ($this->sortKey != PhabricatorProjectColumn::DEFAULT_ORDER) { + $base->setQueryParam('order', $this->sortKey); + } else { + $base->setQueryParam('order', null); + } + + // $base->setQueryParam('hidden', $this->showHidden ? 'true' : null); + + return $base; + } + + +} diff --git a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php --- a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php @@ -91,7 +91,7 @@ $base_uri = '/board/'.$project_id.'/'; $actions = id(new PhabricatorActionListView()) - ->setObjectURI($this->getApplicationURI($base_uri.'column/'.$id.'/')) + ->setObjectURI($this->getApplicationURI($base_uri.'detail/'.$id.'/')) ->setUser($viewer); $can_edit = PhabricatorPolicyFilter::hasCapability( diff --git a/src/applications/project/controller/PhabricatorProjectColumnEditController.php b/src/applications/project/controller/PhabricatorProjectColumnEditController.php --- a/src/applications/project/controller/PhabricatorProjectColumnEditController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnEditController.php @@ -62,7 +62,7 @@ // we want to go back to the board $view_uri = $this->getApplicationURI($base_uri); } else { - $view_uri = $this->getApplicationURI($base_uri.'column/'.$this->id.'/'); + $view_uri = $this->getApplicationURI($base_uri.'detail/'.$this->id.'/'); } if ($request->isFormPost()) { diff --git a/src/view/phui/PHUIWorkpanelView.php b/src/view/phui/PHUIWorkpanelView.php --- a/src/view/phui/PHUIWorkpanelView.php +++ b/src/view/phui/PHUIWorkpanelView.php @@ -5,6 +5,7 @@ private $cards = array(); private $header; private $subheader = null; + private $headerURI; private $footerAction; private $headerColor = PHUIActionHeaderView::HEADER_GREY; private $headerActions = array(); @@ -35,6 +36,11 @@ return $this; } + public function setHeaderURI($header_uri) { + $this->headerURI = $header_uri; + return $this; + } + public function setFooterAction(PHUIListItemView $footer_action) { $this->footerAction = $footer_action; return $this; @@ -108,8 +114,11 @@ array( 'class' => implode(' ', $classes), ), + array( - $header, + isset($this->headerURI) ? + phutil_tag('a', array('href' => $this->headerURI), $header): + $header, $body, $footer, ));