diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 396fb15999..d0580a95ad 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -1,848 +1,852 @@ getUser(); $response = $this->loadProject(); if ($response) { return $response; } $project = $this->getProject(); $this->readRequestState(); $board_uri = $this->getApplicationURI('board/'.$project->getID().'/'); $search_engine = id(new ManiphestTaskSearchEngine()) ->setViewer($viewer) ->setBaseURI($board_uri) ->setIsBoardView(true); if ($request->isFormPost() && !$request->getBool('initialize')) { $saved = $search_engine->buildSavedQueryFromRequest($request); $search_engine->saveQuery($saved); $filter_form = id(new AphrontFormView()) ->setUser($viewer); $search_engine->buildSearchForm($filter_form, $saved); if ($search_engine->getErrors()) { return $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FULL) ->setTitle(pht('Advanced Filter')) ->appendChild($filter_form->buildLayoutView()) ->setErrors($search_engine->getErrors()) ->setSubmitURI($board_uri) ->addSubmitButton(pht('Apply Filter')) ->addCancelButton($board_uri); } return id(new AphrontRedirectResponse())->setURI( $this->getURIWithState( $search_engine->getQueryResultsPageURI($saved->getQueryKey()))); } $query_key = $request->getURIData('queryKey'); if (!$query_key) { $query_key = 'open'; } $this->queryKey = $query_key; $custom_query = null; if ($search_engine->isBuiltinQuery($query_key)) { $saved = $search_engine->buildSavedQueryFromBuiltin($query_key); } else { $saved = id(new PhabricatorSavedQueryQuery()) ->setViewer($viewer) ->withQueryKeys(array($query_key)) ->executeOne(); if (!$saved) { return new Aphront404Response(); } $custom_query = $saved; } if ($request->getURIData('filter')) { $filter_form = id(new AphrontFormView()) ->setUser($viewer); $search_engine->buildSearchForm($filter_form, $saved); return $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FULL) ->setTitle(pht('Advanced Filter')) ->appendChild($filter_form->buildLayoutView()) ->setSubmitURI($board_uri) ->addSubmitButton(pht('Apply Filter')) ->addCancelButton($board_uri); } $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_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()) ->setViewer($viewer) ->setBoardPHIDs(array($board_phid)) ->setObjectPHIDs(array_keys($tasks)) ->executeLayout(); $columns = $layout_engine->getColumns($board_phid); if (!$columns) { $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); if (!$can_edit) { $content = $this->buildNoAccessContent($project); } else { $content = $this->buildInitializeContent($project); } if ($content instanceof AphrontResponse) { return $content; } $nav = $this->getProfileMenu(); $nav->selectFilter(PhabricatorProject::PANEL_WORKBOARD); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Workboard')); return $this->newPage() ->setTitle( array( + $project->getDisplayName(), pht('Workboard'), - $project->getName(), )) ->setNavigation($nav) ->setCrumbs($crumbs) ->appendChild($content); } $task_can_edit_map = id(new PhabricatorPolicyFilter()) ->setViewer($viewer) ->requireCapabilities(array(PhabricatorPolicyCapability::CAN_EDIT)) ->apply($tasks); // If this is a batch edit, select the editable tasks in the chosen column // and ship the user into the batch editor. $batch_edit = $request->getStr('batch'); if ($batch_edit) { if ($batch_edit !== self::BATCH_EDIT_ALL) { $column_id_map = mpull($columns, null, 'getID'); $batch_column = idx($column_id_map, $batch_edit); if (!$batch_column) { return new Aphront404Response(); } $batch_task_phids = $layout_engine->getColumnObjectPHIDs( $board_phid, $batch_column->getPHID()); foreach ($batch_task_phids as $key => $batch_task_phid) { if (empty($task_can_edit_map[$batch_task_phid])) { unset($batch_task_phids[$key]); } } $batch_tasks = array_select_keys($tasks, $batch_task_phids); } else { $batch_tasks = $task_can_edit_map; } if (!$batch_tasks) { $cancel_uri = $this->getURIWithState($board_uri); return $this->newDialog() ->setTitle(pht('No Editable Tasks')) ->appendParagraph( pht( 'The selected column contains no visible tasks which you '. 'have permission to edit.')) ->addCancelButton($board_uri); } $batch_ids = mpull($batch_tasks, 'getID'); $batch_ids = implode(',', $batch_ids); $batch_uri = new PhutilURI('/maniphest/batch/'); $batch_uri->setQueryParam('board', $this->id); $batch_uri->setQueryParam('batch', $batch_ids); return id(new AphrontRedirectResponse()) ->setURI($batch_uri); } $board_id = celerity_generate_unique_node_id(); $board = id(new PHUIWorkboardView()) ->setUser($viewer) ->setID($board_id); $behavior_config = array( 'boardID' => $board_id, 'projectPHID' => $project->getPHID(), 'moveURI' => $this->getApplicationURI('move/'.$project->getID().'/'), 'createURI' => $this->getCreateURI(), 'order' => $this->sortKey, ); $this->initBehavior( 'project-boards', $behavior_config); $this->handles = ManiphestTaskListView::loadTaskHandles($viewer, $tasks); $all_project_phids = array(); foreach ($tasks as $task) { foreach ($task->getProjectPHIDs() as $project_phid) { $all_project_phids[$project_phid] = $project_phid; } } foreach ($select_phids as $phid) { unset($all_project_phids[$phid]); } $all_handles = $viewer->loadHandles($all_project_phids); $all_handles = iterator_to_array($all_handles); foreach ($columns as $column) { if (!$this->showHidden) { if ($column->isHidden()) { continue; } } $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()); $column_tasks = array_select_keys($tasks, $task_phids); // If we aren't using "natural" order, reorder the column by the original // query order. if ($this->sortKey != PhabricatorProjectColumn::ORDER_NATURAL) { $column_tasks = array_select_keys($column_tasks, array_keys($tasks)); } $panel = id(new PHUIWorkpanelView()) ->setHeader($column->getDisplayName()) ->setSubHeader($column->getDisplayType()) ->addSigil('workpanel'); $header_icon = $column->getHeaderIcon(); if ($header_icon) { $panel->setHeaderIcon($header_icon); } $display_class = $column->getDisplayClass(); if ($display_class) { $panel->addClass($display_class); } if ($column->isHidden()) { $panel->addClass('project-panel-hidden'); } $column_menu = $this->buildColumnMenu($project, $column); $panel->addHeaderAction($column_menu); $tag_id = celerity_generate_unique_node_id(); $tag_content_id = celerity_generate_unique_node_id(); $count_tag = id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setShade(PHUITagView::COLOR_BLUE) ->setID($tag_id) ->setName(phutil_tag('span', array('id' => $tag_content_id), '-')) ->setStyle('display: none'); $panel->setHeaderTag($count_tag); $cards = id(new PHUIObjectItemListView()) ->setUser($viewer) ->setFlush(true) ->setAllowEmptyList(true) ->addSigil('project-column') ->setItemClass('phui-workcard') ->setMetadata( array( 'columnPHID' => $column->getPHID(), 'countTagID' => $tag_id, 'countTagContentID' => $tag_content_id, 'pointLimit' => $column->getPointLimit(), )); foreach ($column_tasks as $task) { $owner = null; if ($task->getOwnerPHID()) { $owner = $this->handles[$task->getOwnerPHID()]; } $can_edit = idx($task_can_edit_map, $task->getPHID(), false); $handles = array_select_keys($all_handles, $task->getProjectPHIDs()); $cards->addItem(id(new ProjectBoardTaskCard()) ->setViewer($viewer) ->setProjectHandles($handles) ->setTask($task) ->setOwner($owner) ->setCanEdit($can_edit) ->getItem()); } $panel->setCards($cards); $board->addPanel($panel); } $sort_menu = $this->buildSortMenu( $viewer, $this->sortKey); $filter_menu = $this->buildFilterMenu( $viewer, $custom_query, $search_engine, $query_key); $manage_menu = $this->buildManageMenu($project, $this->showHidden); $header_link = phutil_tag( 'a', array( 'href' => $this->getApplicationURI('profile/'.$project->getID().'/'), ), $project->getName()); $board_box = id(new PHUIBoxView()) ->appendChild($board) ->addClass('project-board-wrapper'); $nav = $this->getProfileMenu(); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Workboard')); $crumbs->setBorder(true); $crumbs->addAction($sort_menu); $crumbs->addAction($filter_menu); $crumbs->addAction($manage_menu); return $this->newPage() - ->setTitle(pht('%s Board', $project->getName())) + ->setTitle( + array( + $project->getDisplayName(), + pht('Workboard'), + )) ->setPageObjectPHIDs(array($project->getPHID())) ->setShowFooter(false) ->setNavigation($nav) ->setCrumbs($crumbs) ->addQuicksandConfig( array( 'boardConfig' => $behavior_config, )) ->appendChild( array( $board_box, )); } private function readRequestState() { $request = $this->getRequest(); $project = $this->getProject(); $this->showHidden = $request->getBool('hidden'); $this->id = $project->getID(); $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; } private function buildSortMenu( PhabricatorUser $viewer, $sort_key) { $sort_icon = id(new PHUIIconView()) ->setIcon('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 PHUIListItemView()) ->setName(pht('Sort: %s', $active_order)) ->setIcon('fa-sort-amount-asc') ->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) { $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->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->id.'/filter/')) ->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 PHUIListItemView()) ->setName(pht('Filter: %s', $active_filter)) ->setIcon('fa-search') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $filter_menu), )); return $filter_button; } private function buildManageMenu( PhabricatorProject $project, $show_hidden) { $request = $this->getRequest(); $viewer = $request->getUser(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $manage_items = array(); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-plus') ->setName(pht('Add Column')) ->setHref($this->getApplicationURI('board/'.$this->id.'/edit/')) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-exchange') ->setName(pht('Reorder Columns')) ->setHref($this->getApplicationURI('board/'.$this->id.'/reorder/')) ->setDisabled(!$can_edit) ->setWorkflow(true); if ($show_hidden) { $hidden_uri = $this->getURIWithState() ->setQueryParam('hidden', null); $hidden_icon = 'fa-eye-slash'; $hidden_text = pht('Hide Hidden Columns'); } else { $hidden_uri = $this->getURIWithState() ->setQueryParam('hidden', 'true'); $hidden_icon = 'fa-eye'; $hidden_text = pht('Show Hidden Columns'); } $manage_items[] = id(new PhabricatorActionView()) ->setIcon($hidden_icon) ->setName($hidden_text) ->setHref($hidden_uri); $batch_edit_uri = $request->getRequestURI(); $batch_edit_uri->setQueryParam('batch', self::BATCH_EDIT_ALL); $can_batch_edit = PhabricatorPolicyFilter::hasCapability( $viewer, PhabricatorApplication::getByClass('PhabricatorManiphestApplication'), ManiphestBulkEditCapability::CAPABILITY); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-list-ul') ->setName(pht('Batch Edit Visible Tasks...')) ->setHref($batch_edit_uri) ->setDisabled(!$can_batch_edit); $manage_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($manage_items as $item) { $manage_menu->addAction($item); } $manage_button = id(new PHUIListItemView()) ->setName(pht('Manage Board')) ->setIcon('fa-cog') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $manage_menu), )); return $manage_button; } private function buildColumnMenu( PhabricatorProject $project, PhabricatorProjectColumn $column) { $request = $this->getRequest(); $viewer = $request->getUser(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $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...')) ->setHref($this->getCreateURI()) ->addSigil('column-add-task') ->setMetadata( array( 'columnPHID' => $column->getPHID(), 'projectPHID' => $default_phid, )); $batch_edit_uri = $request->getRequestURI(); $batch_edit_uri->setQueryParam('batch', $column->getID()); $can_batch_edit = PhabricatorPolicyFilter::hasCapability( $viewer, PhabricatorApplication::getByClass('PhabricatorManiphestApplication'), ManiphestBulkEditCapability::CAPABILITY); $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-list-ul') ->setName(pht('Batch Edit Tasks...')) ->setHref($batch_edit_uri) ->setDisabled(!$can_batch_edit); $detail_uri = $this->getApplicationURI( 'board/'.$this->id.'/column/'.$column->getID().'/'); $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-columns') ->setName(pht('Column Details')) ->setHref($detail_uri); $can_hide = ($can_edit && !$column->isDefaultColumn()); $hide_uri = 'board/'.$this->id.'/hide/'.$column->getID().'/'; $hide_uri = $this->getApplicationURI($hide_uri); $hide_uri = $this->getURIWithState($hide_uri); if (!$column->isHidden()) { $column_items[] = id(new PhabricatorActionView()) ->setName(pht('Hide Column')) ->setIcon('fa-eye-slash') ->setHref($hide_uri) ->setDisabled(!$can_hide) ->setWorkflow(true); } else { $column_items[] = id(new PhabricatorActionView()) ->setName(pht('Show Column')) ->setIcon('fa-eye') ->setHref($hide_uri) ->setDisabled(!$can_hide) ->setWorkflow(true); } $column_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($column_items as $item) { $column_menu->addAction($item); } $column_button = id(new PHUIIconView()) ->setIcon('fa-caret-down') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $column_menu), )); return $column_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; } private function getCreateURI() { $viewer = $this->getViewer(); // TODO: This should be cleaned up, but maybe we're going to make options // for each column or board? $edit_config = id(new ManiphestEditEngine()) ->setViewer($viewer) ->loadDefaultEditConfiguration(); if ($edit_config) { $form_key = $edit_config->getIdentifier(); $create_uri = "/maniphest/task/edit/form/{$form_key}/"; } else { $create_uri = '/maniphest/task/edit/'; } return $create_uri; } private function buildInitializeContent(PhabricatorProject $project) { $request = $this->getRequest(); $viewer = $this->getViewer(); $type = $request->getStr('initialize-type'); $id = $project->getID(); $profile_uri = $this->getApplicationURI("profile/{$id}/"); $board_uri = $this->getApplicationURI("board/{$id}/"); $import_uri = $this->getApplicationURI("board/{$id}/import/"); $set_default = $request->getBool('default'); if ($set_default) { $this ->getProfilePanelEngine() ->adjustDefault(PhabricatorProject::PANEL_WORKBOARD); } if ($request->isFormPost()) { if ($type == 'backlog-only') { $column = PhabricatorProjectColumn::initializeNewColumn($viewer) ->setSequence(0) ->setProperty('isDefault', true) ->setProjectPHID($project->getPHID()) ->save(); $project->setHasWorkboard(1)->save(); return id(new AphrontRedirectResponse()) ->setURI($board_uri); } else { return id(new AphrontRedirectResponse()) ->setURI($import_uri); } } // 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') ->setValue('backlog-only') ->addButton( 'backlog-only', pht('New Empty Board'), pht('Create a new board with just a backlog column.')) ->addButton( 'import', pht('Import Columns'), pht('Import board columns from another project.')); $default_checkbox = id(new AphrontFormCheckboxControl()) ->setLabel(pht('Make Default')) ->addCheckbox( 'default', 1, pht('Make the workboard the default view for this project.'), true); $form = id(new AphrontFormView()) ->setUser($viewer) ->addHiddenInput('initialize', 1) ->appendRemarkupInstructions( pht('The workboard for this project has not been created yet.')) ->appendControl($new_selector) ->appendControl($default_checkbox) ->appendControl( id(new AphrontFormSubmitControl()) ->addCancelButton($profile_uri) ->setValue(pht('Create Workboard'))); $box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Create Workboard')) ->setForm($form); return $box; } private function buildNoAccessContent(PhabricatorProject $project) { $viewer = $this->getViewer(); $id = $project->getID(); $profile_uri = $this->getApplicationURI("profile/{$id}/"); return $this->newDialog() ->setTitle(pht('Unable to Create Workboard')) ->appendParagraph( pht( 'The workboard for this project has not been created yet, '. 'but you do not have permission to create it. Only users '. 'who can edit this project can create a workboard for it.')) ->addCancelButton($profile_uri); } } diff --git a/src/applications/project/controller/PhabricatorProjectManageController.php b/src/applications/project/controller/PhabricatorProjectManageController.php index 2721265493..87420d1aa3 100644 --- a/src/applications/project/controller/PhabricatorProjectManageController.php +++ b/src/applications/project/controller/PhabricatorProjectManageController.php @@ -1,145 +1,149 @@ loadProject(); if ($response) { return $response; } $viewer = $request->getUser(); $project = $this->getProject(); $id = $project->getID(); $picture = $project->getProfileImageURI(); $header = id(new PHUIHeaderView()) ->setHeader(pht('Project History')) ->setUser($viewer) ->setPolicyObject($project) ->setImage($picture); if ($project->getStatus() == PhabricatorProjectStatus::STATUS_ACTIVE) { $header->setStatus('fa-check', 'bluegrey', pht('Active')); } else { $header->setStatus('fa-ban', 'red', pht('Archived')); } $actions = $this->buildActionListView($project); $properties = $this->buildPropertyListView($project, $actions); $object_box = id(new PHUIObjectBoxView()) ->setHeader($header) ->addPropertyList($properties); $timeline = $this->buildTransactionTimeline( $project, new PhabricatorProjectTransactionQuery()); $timeline->setShouldTerminate(true); $nav = $this->getProfileMenu(); $nav->selectFilter(PhabricatorProject::PANEL_MANAGE); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Manage')); return $this->newPage() ->setNavigation($nav) ->setCrumbs($crumbs) - ->setTitle($project->getName()) + ->setTitle( + array( + $project->getDisplayName(), + pht('Manage'), + )) ->appendChild( array( $object_box, $timeline, )); } private function buildActionListView(PhabricatorProject $project) { $request = $this->getRequest(); $viewer = $request->getUser(); $id = $project->getID(); $view = id(new PhabricatorActionListView()) ->setUser($viewer); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Details')) ->setIcon('fa-pencil') ->setHref($this->getApplicationURI("edit/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Menu')) ->setIcon('fa-th-list') ->setHref($this->getApplicationURI("{$id}/panel/configure/")) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Picture')) ->setIcon('fa-picture-o') ->setHref($this->getApplicationURI("picture/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); if ($project->isArchived()) { $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Activate Project')) ->setIcon('fa-check') ->setHref($this->getApplicationURI("archive/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(true)); } else { $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Archive Project')) ->setIcon('fa-ban') ->setHref($this->getApplicationURI("archive/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(true)); } return $view; } private function buildPropertyListView( PhabricatorProject $project, PhabricatorActionListView $actions) { $request = $this->getRequest(); $viewer = $request->getUser(); $view = id(new PHUIPropertyListView()) ->setUser($viewer) ->setActionList($actions); $view->addProperty( pht('Looks Like'), $viewer->renderHandle($project->getPHID())->setAsTag(true)); $field_list = PhabricatorCustomField::getObjectFields( $project, PhabricatorCustomField::ROLE_VIEW); $field_list->appendFieldsToPropertyList($project, $viewer, $view); return $view; } } diff --git a/src/applications/project/controller/PhabricatorProjectMembersViewController.php b/src/applications/project/controller/PhabricatorProjectMembersViewController.php index efe2106a26..88bc56be24 100644 --- a/src/applications/project/controller/PhabricatorProjectMembersViewController.php +++ b/src/applications/project/controller/PhabricatorProjectMembersViewController.php @@ -1,283 +1,283 @@ getViewer(); $id = $request->getURIData('id'); $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->needMembers(true) ->needWatchers(true) ->needImages(true) ->executeOne(); if (!$project) { return new Aphront404Response(); } $this->setProject($project); $title = pht('Members and Watchers'); $properties = $this->buildProperties($project); $actions = $this->buildActions($project); $properties->setActionList($actions); $object_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->addPropertyList($properties); $member_list = id(new PhabricatorProjectMemberListView()) ->setUser($viewer) ->setProject($project) ->setUserPHIDs($project->getMemberPHIDs()); $watcher_list = id(new PhabricatorProjectWatcherListView()) ->setUser($viewer) ->setProject($project) ->setUserPHIDs($project->getWatcherPHIDs()); $nav = $this->getProfileMenu(); $nav->selectFilter(PhabricatorProject::PANEL_MEMBERS); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Members')); return $this->newPage() ->setNavigation($nav) ->setCrumbs($crumbs) - ->setTitle(array($project->getName(), $title)) + ->setTitle(array($project->getDisplayName(), $title)) ->appendChild( array( $object_box, $member_list, $watcher_list, )); } private function buildProperties(PhabricatorProject $project) { $viewer = $this->getViewer(); $view = id(new PHUIPropertyListView()) ->setUser($viewer) ->setObject($project); if ($project->isMilestone()) { $icon_key = PhabricatorProjectIconSet::getMilestoneIconKey(); $icon = PhabricatorProjectIconSet::getIconIcon($icon_key); $target = PhabricatorProjectIconSet::getIconName($icon_key); $note = pht( 'Members of the parent project are members of this project.'); $show_join = false; } else if ($project->getHasSubprojects()) { $icon = 'fa-sitemap'; $target = pht('Parent Project'); $note = pht( 'Members of all subprojects are members of this project.'); $show_join = false; } else if ($project->getIsMembershipLocked()) { $icon = 'fa-lock'; $target = pht('Locked Project'); $note = pht( 'Users with access may join this project, but may not leave.'); $show_join = true; } else { $icon = 'fa-briefcase'; $target = pht('Normal Project'); $note = pht('Users with access may join and leave this project.'); $show_join = true; } $item = id(new PHUIStatusItemView()) ->setIcon($icon) ->setTarget(phutil_tag('strong', array(), $target)) ->setNote($note); $status = id(new PHUIStatusListView()) ->addItem($item); $view->addProperty(pht('Membership'), $status); if ($show_join) { $descriptions = PhabricatorPolicyQuery::renderPolicyDescriptions( $viewer, $project); $view->addProperty( pht('Joinable By'), $descriptions[PhabricatorPolicyCapability::CAN_JOIN]); } $viewer_phid = $viewer->getPHID(); if ($project->isUserWatcher($viewer_phid)) { $watch_item = id(new PHUIStatusItemView()) ->setIcon('fa-eye green') ->setTarget(phutil_tag('strong', array(), pht('Watching'))) ->setNote( pht( 'You will receive mail about changes made to any related '. 'object.')); $watch_status = id(new PHUIStatusListView()) ->addItem($watch_item); $view->addProperty(pht('Watching'), $watch_status); } if ($project->isUserMember($viewer_phid)) { $is_silenced = $this->isProjectSilenced($project); if ($is_silenced) { $mail_icon = 'fa-envelope-o grey'; $mail_target = pht('Disabled'); $mail_note = pht( 'When mail is sent to project members, you will not receive '. 'a copy.'); } else { $mail_icon = 'fa-envelope-o green'; $mail_target = pht('Enabled'); $mail_note = pht( 'You will receive mail that is sent to project members.'); } $mail_item = id(new PHUIStatusItemView()) ->setIcon($mail_icon) ->setTarget(phutil_tag('strong', array(), $mail_target)) ->setNote($mail_note); $mail_status = id(new PHUIStatusListView()) ->addItem($mail_item); $view->addProperty(pht('Mail to Members'), $mail_status); } return $view; } private function buildActions(PhabricatorProject $project) { $viewer = $this->getViewer(); $id = $project->getID(); $view = id(new PhabricatorActionListView()) ->setUser($viewer); $is_locked = $project->getIsMembershipLocked(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $supports_edit = $project->supportsEditMembers(); $can_join = $supports_edit && PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_JOIN); $can_leave = $supports_edit && (!$is_locked || $can_edit); $viewer_phid = $viewer->getPHID(); if (!$project->isUserMember($viewer_phid)) { $view->addAction( id(new PhabricatorActionView()) ->setHref('/project/update/'.$project->getID().'/join/') ->setIcon('fa-plus') ->setDisabled(!$can_join) ->setWorkflow(true) ->setName(pht('Join Project'))); } else { $view->addAction( id(new PhabricatorActionView()) ->setHref('/project/update/'.$project->getID().'/leave/') ->setIcon('fa-times') ->setDisabled(!$can_leave) ->setWorkflow(true) ->setName(pht('Leave Project'))); } if (!$project->isUserWatcher($viewer->getPHID())) { $view->addAction( id(new PhabricatorActionView()) ->setWorkflow(true) ->setHref('/project/watch/'.$project->getID().'/') ->setIcon('fa-eye') ->setName(pht('Watch Project'))); } else { $view->addAction( id(new PhabricatorActionView()) ->setWorkflow(true) ->setHref('/project/unwatch/'.$project->getID().'/') ->setIcon('fa-eye-slash') ->setName(pht('Unwatch Project'))); } $can_silence = $project->isUserMember($viewer_phid); $is_silenced = $this->isProjectSilenced($project); if ($is_silenced) { $silence_text = pht('Enable Mail'); } else { $silence_text = pht('Disable Mail'); } $view->addAction( id(new PhabricatorActionView()) ->setName($silence_text) ->setIcon('fa-envelope-o') ->setHref("/project/silence/{$id}/") ->setWorkflow(true) ->setDisabled(!$can_silence)); $can_add = $can_edit && $supports_edit; $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Add Members')) ->setIcon('fa-user-plus') ->setHref("/project/members/{$id}/add/") ->setWorkflow(true) ->setDisabled(!$can_add)); $can_lock = $can_edit && $supports_edit && $this->hasApplicationCapability( ProjectCanLockProjectsCapability::CAPABILITY); if ($is_locked) { $lock_name = pht('Unlock Project'); $lock_icon = 'fa-unlock'; } else { $lock_name = pht('Lock Project'); $lock_icon = 'fa-lock'; } $view->addAction( id(new PhabricatorActionView()) ->setName($lock_name) ->setIcon($lock_icon) ->setHref($this->getApplicationURI("lock/{$id}/")) ->setDisabled(!$can_lock) ->setWorkflow(true)); return $view; } private function isProjectSilenced(PhabricatorProject $project) { $viewer = $this->getViewer(); $viewer_phid = $viewer->getPHID(); if (!$viewer_phid) { return false; } $edge_type = PhabricatorProjectSilencedEdgeType::EDGECONST; $silenced = PhabricatorEdgeQuery::loadDestinationPHIDs( $project->getPHID(), $edge_type); $silenced = array_fuse($silenced); return isset($silenced[$viewer_phid]); } } diff --git a/src/applications/project/controller/PhabricatorProjectProfileController.php b/src/applications/project/controller/PhabricatorProjectProfileController.php index 8bd03b7053..7e5f066607 100644 --- a/src/applications/project/controller/PhabricatorProjectProfileController.php +++ b/src/applications/project/controller/PhabricatorProjectProfileController.php @@ -1,277 +1,277 @@ loadProject(); if ($response) { return $response; } $viewer = $request->getUser(); $project = $this->getProject(); $id = $project->getID(); $picture = $project->getProfileImageURI(); $icon = $project->getDisplayIconIcon(); $icon_name = $project->getDisplayIconName(); $tag = id(new PHUITagView()) ->setIcon($icon) ->setName($icon_name) ->addClass('project-view-header-tag') ->setType(PHUITagView::TYPE_SHADE); $header = id(new PHUIHeaderView()) ->setHeader(array($project->getName(), $tag)) ->setUser($viewer) ->setPolicyObject($project) ->setImage($picture) ->setProfileHeader(true); if ($project->getStatus() == PhabricatorProjectStatus::STATUS_ACTIVE) { $header->setStatus('fa-check', 'bluegrey', pht('Active')); } else { $header->setStatus('fa-ban', 'red', pht('Archived')); } $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); if ($can_edit) { $header->setImageEditURL($this->getApplicationURI("picture/{$id}/")); } $properties = $this->buildPropertyListView($project); $watch_action = $this->renderWatchAction($project); $header->addActionLink($watch_action); $milestone_list = $this->buildMilestoneList($project); $subproject_list = $this->buildSubprojectList($project); $member_list = id(new PhabricatorProjectMemberListView()) ->setUser($viewer) ->setProject($project) ->setLimit(5) ->setBackground(PHUIBoxView::GREY) ->setUserPHIDs($project->getMemberPHIDs()); $watcher_list = id(new PhabricatorProjectWatcherListView()) ->setUser($viewer) ->setProject($project) ->setLimit(5) ->setBackground(PHUIBoxView::GREY) ->setUserPHIDs($project->getWatcherPHIDs()); $nav = $this->getProfileMenu(); $nav->selectFilter(PhabricatorProject::PANEL_PROFILE); $stories = id(new PhabricatorFeedQuery()) ->setViewer($viewer) ->setFilterPHIDs( array( $project->getPHID(), )) ->setLimit(50) ->execute(); $feed = $this->renderStories($stories); $feed = phutil_tag_div('project-view-feed', $feed); $columns = id(new PHUITwoColumnView()) ->setMainColumn( array( $properties, $feed, )) ->setSideColumn( array( $milestone_list, $subproject_list, $member_list, $watcher_list, )); $crumbs = $this->buildApplicationCrumbs(); $crumbs->setBorder(true); require_celerity_resource('project-view-css'); $home = phutil_tag( 'div', array( 'class' => 'project-view-home', ), array( $header, $columns, )); return $this->newPage() ->setNavigation($nav) ->setCrumbs($crumbs) - ->setTitle($project->getName()) + ->setTitle($project->getDisplayName()) ->setPageObjectPHIDs(array($project->getPHID())) ->appendChild( array( $home, )); } private function buildPropertyListView( PhabricatorProject $project) { $request = $this->getRequest(); $viewer = $request->getUser(); $view = id(new PHUIPropertyListView()) ->setUser($viewer) ->setObject($project); $field_list = PhabricatorCustomField::getObjectFields( $project, PhabricatorCustomField::ROLE_VIEW); $field_list->appendFieldsToPropertyList($project, $viewer, $view); if (!$view->hasAnyProperties()) { return null; } $view = id(new PHUIBoxView()) ->setColor(PHUIBoxView::GREY) ->appendChild($view) ->addClass('project-view-properties'); return $view; } private function renderStories(array $stories) { assert_instances_of($stories, 'PhabricatorFeedStory'); $builder = new PhabricatorFeedBuilder($stories); $builder->setUser($this->getRequest()->getUser()); $builder->setShowHovercards(true); $view = $builder->buildView(); return $view; } private function renderWatchAction(PhabricatorProject $project) { $viewer = $this->getViewer(); $viewer_phid = $viewer->getPHID(); $id = $project->getID(); $is_watcher = ($viewer_phid && $project->isUserWatcher($viewer_phid)); if (!$is_watcher) { $watch_icon = 'fa-eye'; $watch_text = pht('Watch Project'); $watch_href = "/project/watch/{$id}/?via=profile"; } else { $watch_icon = 'fa-eye-slash'; $watch_text = pht('Unwatch Project'); $watch_href = "/project/unwatch/{$id}/?via=profile"; } $watch_icon = id(new PHUIIconView()) ->setIcon($watch_icon); return id(new PHUIButtonView()) ->setTag('a') ->setWorkflow(true) ->setIcon($watch_icon) ->setText($watch_text) ->setHref($watch_href); } private function buildMilestoneList(PhabricatorProject $project) { if (!$project->getHasMilestones()) { return null; } $viewer = $this->getViewer(); $id = $project->getID(); $milestones = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withParentProjectPHIDs(array($project->getPHID())) ->needImages(true) ->withIsMilestone(true) ->setOrder('newest') ->execute(); if (!$milestones) { return null; } $milestone_list = id(new PhabricatorProjectListView()) ->setUser($viewer) ->setProjects($milestones) ->renderList(); $view_all = id(new PHUIButtonView()) ->setTag('a') ->setIcon( id(new PHUIIconView()) ->setIcon('fa-list-ul')) ->setText(pht('View All')) ->setHref("/project/subprojects/{$id}/"); $header = id(new PHUIHeaderView()) ->setHeader(pht('Milestones')) ->addActionLink($view_all); return id(new PHUIObjectBoxView()) ->setHeader($header) ->setBackground(PHUIBoxView::GREY) ->setObjectList($milestone_list); } private function buildSubprojectList(PhabricatorProject $project) { if (!$project->getHasSubprojects()) { return null; } $viewer = $this->getViewer(); $id = $project->getID(); $limit = 25; $subprojects = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withParentProjectPHIDs(array($project->getPHID())) ->needImages(true) ->withIsMilestone(false) ->setLimit($limit) ->execute(); if (!$subprojects) { return null; } $subproject_list = id(new PhabricatorProjectListView()) ->setUser($viewer) ->setProjects($subprojects) ->renderList(); $view_all = id(new PHUIButtonView()) ->setTag('a') ->setIcon( id(new PHUIIconView()) ->setIcon('fa-list-ul')) ->setText(pht('View All')) ->setHref("/project/subprojects/{$id}/"); $header = id(new PHUIHeaderView()) ->setHeader(pht('Subprojects')) ->addActionLink($view_all); return id(new PHUIObjectBoxView()) ->setHeader($header) ->setBackground(PHUIBoxView::GREY) ->setObjectList($subproject_list); } } diff --git a/src/applications/project/events/PhabricatorProjectUIEventListener.php b/src/applications/project/events/PhabricatorProjectUIEventListener.php index c85c036f51..afbc0aab4e 100644 --- a/src/applications/project/events/PhabricatorProjectUIEventListener.php +++ b/src/applications/project/events/PhabricatorProjectUIEventListener.php @@ -1,105 +1,107 @@ listen(PhabricatorEventType::TYPE_UI_WILLRENDERPROPERTIES); } public function handleEvent(PhutilEvent $event) { switch ($event->getType()) { case PhabricatorEventType::TYPE_UI_WILLRENDERPROPERTIES: $this->handlePropertyEvent($event); break; } } private function handlePropertyEvent($event) { $user = $event->getUser(); $object = $event->getValue('object'); if (!$object || !$object->getPHID()) { // No object, or the object has no PHID yet.. return; } if (!($object instanceof PhabricatorProjectInterface)) { // This object doesn't have projects. return; } $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); if ($project_phids) { $project_phids = array_reverse($project_phids); $handles = id(new PhabricatorHandleQuery()) ->setViewer($user) ->withPHIDs($project_phids) ->execute(); } else { $handles = array(); } // If this object can appear on boards, build the workboard annotations. // Some day, this might be a generic interface. For now, only tasks can // appear on boards. $can_appear_on_boards = ($object instanceof ManiphestTask); $annotations = array(); if ($handles && $can_appear_on_boards) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($user) ->setBoardPHIDs($project_phids) ->setObjectPHIDs(array($object->getPHID())) ->executeLayout(); // TDOO: Generalize this UI and move it out of Maniphest. require_celerity_resource('maniphest-task-summary-css'); foreach ($project_phids as $project_phid) { $handle = $handles[$project_phid]; $columns = $engine->getObjectColumns( $project_phid, $object->getPHID()); $annotation = array(); foreach ($columns as $column) { + $project_id = $column->getProject()->getID(); + $column_name = pht('(%s)', $column->getDisplayName()); $column_link = phutil_tag( 'a', array( - 'href' => $handle->getURI().'board/', + 'href' => "/project/board/{$project_id}/", 'class' => 'maniphest-board-link', ), $column_name); $annotation[] = $column_link; } if ($annotation) { $annotations[$project_phid] = array( ' ', phutil_implode_html(', ', $annotation), ); } } } if ($handles) { $list = id(new PHUIHandleTagListView()) ->setHandles($handles) ->setAnnotations($annotations) ->setShowHovercards(true); } else { $list = phutil_tag('em', array(), pht('None')); } $view = $event->getValue('view'); $view->addProperty(pht('Projects'), $list); } } diff --git a/src/applications/project/phid/PhabricatorProjectProjectPHIDType.php b/src/applications/project/phid/PhabricatorProjectProjectPHIDType.php index b9fdefc6b3..7a04103a7c 100644 --- a/src/applications/project/phid/PhabricatorProjectProjectPHIDType.php +++ b/src/applications/project/phid/PhabricatorProjectProjectPHIDType.php @@ -1,116 +1,116 @@ withPHIDs($phids) ->needImages(true); } public function loadHandles( PhabricatorHandleQuery $query, array $handles, array $objects) { foreach ($handles as $phid => $handle) { $project = $objects[$phid]; - $name = $project->getName(); + $name = $project->getDisplayName(); $id = $project->getID(); $slug = $project->getPrimarySlug(); $handle->setName($name); if (strlen($slug)) { $handle->setObjectName('#'.$slug); $handle->setURI("/tag/{$slug}/"); } else { $handle->setURI("/project/view/{$id}/"); } $handle->setImageURI($project->getProfileImageURI()); $handle->setIcon($project->getDisplayIconIcon()); $handle->setTagColor($project->getDisplayColor()); if ($project->isArchived()) { $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); } } } public static function getProjectMonogramPatternFragment() { // NOTE: See some discussion in ProjectRemarkupRule. return '[^\s,#]+'; } public function canLoadNamedObject($name) { $fragment = self::getProjectMonogramPatternFragment(); return preg_match('/^#'.$fragment.'$/i', $name); } public function loadNamedObjects( PhabricatorObjectQuery $query, array $names) { // If the user types "#YoloSwag", we still want to match "#yoloswag", so // we normalize, query, and then map back to the original inputs. $map = array(); foreach ($names as $key => $slug) { $map[$this->normalizeSlug(substr($slug, 1))][] = $slug; } $projects = id(new PhabricatorProjectQuery()) ->setViewer($query->getViewer()) ->withSlugs(array_keys($map)) ->needSlugs(true) ->execute(); $result = array(); foreach ($projects as $project) { $slugs = $project->getSlugs(); $slug_strs = mpull($slugs, 'getSlug'); foreach ($slug_strs as $slug) { $slug_map = idx($map, $slug, array()); foreach ($slug_map as $original) { $result[$original] = $project; } } } return $result; } private function normalizeSlug($slug) { // NOTE: We're using phutil_utf8_strtolower() (and not PhabricatorSlug's // normalize() method) because this normalization should be only somewhat // liberal. We want "#YOLO" to match against "#yolo", but "#\\yo!!lo" // should not. normalize() strips out most punctuation and leads to // excessively aggressive matches. return phutil_utf8_strtolower($slug); } } diff --git a/src/applications/project/storage/PhabricatorProject.php b/src/applications/project/storage/PhabricatorProject.php index b3d23a089e..ad7fb525c0 100644 --- a/src/applications/project/storage/PhabricatorProject.php +++ b/src/applications/project/storage/PhabricatorProject.php @@ -1,688 +1,702 @@ setViewer(PhabricatorUser::getOmnipotentUser()) ->withClasses(array('PhabricatorProjectApplication')) ->executeOne(); $view_policy = $app->getPolicy( ProjectDefaultViewCapability::CAPABILITY); $edit_policy = $app->getPolicy( ProjectDefaultEditCapability::CAPABILITY); $join_policy = $app->getPolicy( ProjectDefaultJoinCapability::CAPABILITY); $default_icon = PhabricatorProjectIconSet::getDefaultIconKey(); $default_color = PhabricatorProjectIconSet::getDefaultColorKey(); return id(new PhabricatorProject()) ->setAuthorPHID($actor->getPHID()) ->setIcon($default_icon) ->setColor($default_color) ->setViewPolicy($view_policy) ->setEditPolicy($edit_policy) ->setJoinPolicy($join_policy) ->setIsMembershipLocked(0) ->attachMemberPHIDs(array()) ->attachSlugs(array()) ->setHasWorkboard(0) ->setHasMilestones(0) ->setHasSubprojects(0) ->attachParentProject(null); } public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, PhabricatorPolicyCapability::CAN_JOIN, ); } public function getPolicy($capability) { if ($this->isMilestone()) { return $this->getParentProject()->getPolicy($capability); } switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); case PhabricatorPolicyCapability::CAN_JOIN: return $this->getJoinPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { if ($this->isMilestone()) { return $this->getParentProject()->hasAutomaticCapability( $capability, $viewer); } $can_edit = PhabricatorPolicyCapability::CAN_EDIT; switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if ($this->isUserMember($viewer->getPHID())) { // Project members can always view a project. return true; } break; case PhabricatorPolicyCapability::CAN_EDIT: $parent = $this->getParentProject(); if ($parent) { $can_edit_parent = PhabricatorPolicyFilter::hasCapability( $viewer, $parent, $can_edit); if ($can_edit_parent) { return true; } } break; case PhabricatorPolicyCapability::CAN_JOIN: if (PhabricatorPolicyFilter::hasCapability($viewer, $this, $can_edit)) { // Project editors can always join a project. return true; } break; } return false; } public function describeAutomaticCapability($capability) { // TODO: Clarify the additional rules that parent and subprojects imply. switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return pht('Members of a project can always view it.'); case PhabricatorPolicyCapability::CAN_JOIN: return pht('Users who can edit a project can always join it.'); } return null; } public function getExtendedPolicy($capability, PhabricatorUser $viewer) { $extended = array(); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $parent = $this->getParentProject(); if ($parent) { $extended[] = array( $parent, PhabricatorPolicyCapability::CAN_VIEW, ); } break; } return $extended; } public function isUserMember($user_phid) { if ($this->memberPHIDs !== self::ATTACHABLE) { return in_array($user_phid, $this->memberPHIDs); } return $this->assertAttachedKey($this->sparseMembers, $user_phid); } public function setIsUserMember($user_phid, $is_member) { if ($this->sparseMembers === self::ATTACHABLE) { $this->sparseMembers = array(); } $this->sparseMembers[$user_phid] = $is_member; return $this; } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'sort128', 'status' => 'text32', 'primarySlug' => 'text128?', 'isMembershipLocked' => 'bool', 'profileImagePHID' => 'phid?', 'icon' => 'text32', 'color' => 'text32', 'mailKey' => 'bytes20', 'joinPolicy' => 'policy', 'parentProjectPHID' => 'phid?', 'hasWorkboard' => 'bool', 'hasMilestones' => 'bool', 'hasSubprojects' => 'bool', 'milestoneNumber' => 'uint32?', 'projectPath' => 'hashpath64', 'projectDepth' => 'uint32', 'projectPathKey' => 'bytes4', ), self::CONFIG_KEY_SCHEMA => array( 'key_icon' => array( 'columns' => array('icon'), ), 'key_color' => array( 'columns' => array('color'), ), 'key_milestone' => array( 'columns' => array('parentProjectPHID', 'milestoneNumber'), 'unique' => true, ), 'key_primaryslug' => array( 'columns' => array('primarySlug'), 'unique' => true, ), 'key_path' => array( 'columns' => array('projectPath', 'projectDepth'), ), 'key_pathkey' => array( 'columns' => array('projectPathKey'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorProjectProjectPHIDType::TYPECONST); } public function attachMemberPHIDs(array $phids) { $this->memberPHIDs = $phids; return $this; } public function getMemberPHIDs() { return $this->assertAttached($this->memberPHIDs); } public function isArchived() { return ($this->getStatus() == PhabricatorProjectStatus::STATUS_ARCHIVED); } public function getProfileImageURI() { return $this->getProfileImageFile()->getBestURI(); } public function attachProfileImageFile(PhabricatorFile $file) { $this->profileImageFile = $file; return $this; } public function getProfileImageFile() { return $this->assertAttached($this->profileImageFile); } public function isUserWatcher($user_phid) { if ($this->watcherPHIDs !== self::ATTACHABLE) { return in_array($user_phid, $this->watcherPHIDs); } return $this->assertAttachedKey($this->sparseWatchers, $user_phid); } public function setIsUserWatcher($user_phid, $is_watcher) { if ($this->sparseWatchers === self::ATTACHABLE) { $this->sparseWatchers = array(); } $this->sparseWatchers[$user_phid] = $is_watcher; return $this; } public function attachWatcherPHIDs(array $phids) { $this->watcherPHIDs = $phids; return $this; } public function getWatcherPHIDs() { return $this->assertAttached($this->watcherPHIDs); } public function attachSlugs(array $slugs) { $this->slugs = $slugs; return $this; } public function getSlugs() { return $this->assertAttached($this->slugs); } public function getColor() { if ($this->isArchived()) { return PHUITagView::COLOR_DISABLED; } return $this->color; } public function getURI() { $id = $this->getID(); return "/project/view/{$id}/"; } public function save() { if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); } if (!strlen($this->getPHID())) { $this->setPHID($this->generatePHID()); } if (!strlen($this->getProjectPathKey())) { $hash = PhabricatorHash::digestForIndex($this->getPHID()); $hash = substr($hash, 0, 4); $this->setProjectPathKey($hash); } $path = array(); $depth = 0; if ($this->parentProjectPHID) { $parent = $this->getParentProject(); $path[] = $parent->getProjectPath(); $depth = $parent->getProjectDepth() + 1; } $path[] = $this->getProjectPathKey(); $path = implode('', $path); $limit = self::getProjectDepthLimit(); if ($depth >= $limit) { throw new Exception(pht('Project depth is too great.')); } $this->setProjectPath($path); $this->setProjectDepth($depth); $this->openTransaction(); $result = parent::save(); $this->updateDatasourceTokens(); $this->saveTransaction(); return $result; } public static function getProjectDepthLimit() { // This is limited by how many path hashes we can fit in the path // column. return 16; } public function updateDatasourceTokens() { $table = self::TABLE_DATASOURCE_TOKEN; $conn_w = $this->establishConnection('w'); $id = $this->getID(); $slugs = queryfx_all( $conn_w, 'SELECT * FROM %T WHERE projectPHID = %s', id(new PhabricatorProjectSlug())->getTableName(), $this->getPHID()); $all_strings = ipull($slugs, 'slug'); $all_strings[] = $this->getName(); $all_strings = implode(' ', $all_strings); $tokens = PhabricatorTypeaheadDatasource::tokenizeString($all_strings); $sql = array(); foreach ($tokens as $token) { $sql[] = qsprintf($conn_w, '(%d, %s)', $id, $token); } $this->openTransaction(); queryfx( $conn_w, 'DELETE FROM %T WHERE projectID = %d', $table, $id); foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) { queryfx( $conn_w, 'INSERT INTO %T (projectID, token) VALUES %Q', $table, $chunk); } $this->saveTransaction(); } public function isMilestone() { return ($this->getMilestoneNumber() !== null); } public function getParentProject() { return $this->assertAttached($this->parentProject); } public function attachParentProject(PhabricatorProject $project = null) { $this->parentProject = $project; return $this; } public function getAncestorProjectPaths() { $parts = array(); $path = $this->getProjectPath(); $parent_length = (strlen($path) - 4); for ($ii = $parent_length; $ii > 0; $ii -= 4) { $parts[] = substr($path, 0, $ii); } return $parts; } public function getAncestorProjects() { $ancestors = array(); $cursor = $this->getParentProject(); while ($cursor) { $ancestors[] = $cursor; $cursor = $cursor->getParentProject(); } return $ancestors; } public function supportsEditMembers() { if ($this->isMilestone()) { return false; } if ($this->getHasSubprojects()) { return false; } return true; } public function supportsMilestones() { if ($this->isMilestone()) { return false; } return true; } public function supportsSubprojects() { if ($this->isMilestone()) { return false; } return true; } public function loadNextMilestoneNumber() { $current = queryfx_one( $this->establishConnection('w'), 'SELECT MAX(milestoneNumber) n FROM %T WHERE parentProjectPHID = %s', $this->getTableName(), $this->getPHID()); if (!$current) { $number = 1; } else { $number = (int)$current['n'] + 1; } return $number; } + public function getDisplayName() { + $name = $this->getName(); + + // If this is a milestone, show it as "Parent > Sprint 99". + if ($this->isMilestone()) { + $name = pht( + '%s (%s)', + $this->getParentProject()->getName(), + $name); + } + + return $name; + } + public function getDisplayIconKey() { if ($this->isMilestone()) { $key = PhabricatorProjectIconSet::getMilestoneIconKey(); } else { $key = $this->getIcon(); } return $key; } public function getDisplayIconIcon() { $key = $this->getDisplayIconKey(); return PhabricatorProjectIconSet::getIconIcon($key); } public function getDisplayIconName() { $key = $this->getDisplayIconKey(); return PhabricatorProjectIconSet::getIconName($key); } public function getDisplayColor() { if ($this->isMilestone()) { return PhabricatorProjectIconSet::getDefaultColorKey(); } return $this->getColor(); } public function getDisplayIconComposeIcon() { $icon = $this->getDisplayIconIcon(); return $icon; } public function getDisplayIconComposeColor() { $color = $this->getDisplayColor(); $map = array( 'grey' => 'charcoal', 'checkered' => 'backdrop', ); return idx($map, $color, $color); } /* -( PhabricatorCustomFieldInterface )------------------------------------ */ public function getCustomFieldSpecificationForRole($role) { return PhabricatorEnv::getEnvConfig('projects.fields'); } public function getCustomFieldBaseClass() { return 'PhabricatorProjectCustomField'; } public function getCustomFields() { return $this->assertAttached($this->customFields); } public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { $this->customFields = $fields; return $this; } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorProjectTransactionEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorProjectTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $columns = id(new PhabricatorProjectColumn()) ->loadAllWhere('projectPHID = %s', $this->getPHID()); foreach ($columns as $column) { $engine->destroyObject($column); } $slugs = id(new PhabricatorProjectSlug()) ->loadAllWhere('projectPHID = %s', $this->getPHID()); foreach ($slugs as $slug) { $slug->delete(); } $this->saveTransaction(); } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhabricatorProjectFulltextEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('name') ->setType('string') ->setDescription(pht('The name of the project.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('slug') ->setType('string') ->setDescription(pht('Primary slug/hashtag.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('icon') ->setType('map') ->setDescription(pht('Information about the project icon.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('color') ->setType('map') ->setDescription(pht('Information about the project color.')), ); } public function getFieldValuesForConduit() { $color_key = $this->getColor(); $color_name = PhabricatorProjectIconSet::getColorName($color_key); return array( 'name' => $this->getName(), 'slug' => $this->getPrimarySlug(), 'icon' => array( 'key' => $this->getDisplayIconKey(), 'name' => $this->getDisplayIconName(), 'icon' => $this->getDisplayIconIcon(), ), 'color' => array( 'key' => $color_key, 'name' => $color_name, ), ); } public function getConduitSearchAttachments() { return array( id(new PhabricatorProjectsMembersSearchEngineAttachment()) ->setAttachmentKey('members'), id(new PhabricatorProjectsWatchersSearchEngineAttachment()) ->setAttachmentKey('watchers'), ); } /* -( 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/typeahead/PhabricatorProjectDatasource.php b/src/applications/project/typeahead/PhabricatorProjectDatasource.php index cc0140878f..d902c9c392 100644 --- a/src/applications/project/typeahead/PhabricatorProjectDatasource.php +++ b/src/applications/project/typeahead/PhabricatorProjectDatasource.php @@ -1,94 +1,94 @@ getViewer(); $raw_query = $this->getRawQuery(); // Allow users to type "#qa" or "qa" to find "Quality Assurance". $raw_query = ltrim($raw_query, '#'); $tokens = self::tokenizeString($raw_query); $query = id(new PhabricatorProjectQuery()) ->needImages(true) ->needSlugs(true); if ($tokens) { $query->withNameTokens($tokens); } // If this is for policy selection, prevent users from using milestones. $for_policy = $this->getParameter('policy'); if ($for_policy) { $query->withIsMilestone(false); } $projs = $this->executeQuery($query); $projs = mpull($projs, null, 'getPHID'); $must_have_cols = $this->getParameter('mustHaveColumns', false); if ($must_have_cols) { $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array_keys($projs)) ->execute(); $has_cols = mgroup($columns, 'getProjectPHID'); } else { $has_cols = array_fill_keys(array_keys($projs), true); } $results = array(); foreach ($projs as $proj) { if (!isset($has_cols[$proj->getPHID()])) { continue; } $closed = null; if ($proj->isArchived()) { $closed = pht('Archived'); } $all_strings = mpull($proj->getSlugs(), 'getSlug'); - $all_strings[] = $proj->getName(); + $all_strings[] = $proj->getDisplayName(); $all_strings = implode(' ', $all_strings); $proj_result = id(new PhabricatorTypeaheadResult()) ->setName($all_strings) - ->setDisplayName($proj->getName()) + ->setDisplayName($proj->getDisplayName()) ->setDisplayType(pht('Project')) ->setURI($proj->getURI()) ->setPHID($proj->getPHID()) ->setIcon($proj->getDisplayIconIcon()) ->setColor($proj->getColor()) ->setPriorityType('proj') ->setClosed($closed); $slug = $proj->getPrimarySlug(); if (strlen($slug)) { $proj_result->setAutocomplete('#'.$slug); } $proj_result->setImageURI($proj->getProfileImageURI()); $results[] = $proj_result; } return $results; } }