diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 5198f44572..fd5bfe0cdc 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -1,858 +1,857 @@ getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: return null; } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: return $xaction->getNewValue(); } } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: return (bool)$new; } return parent::transactionHasEffect($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: return; } } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: foreach ($xaction->getNewValue() as $move) { $this->applyBoardMove($object, $move); } break; } } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { // When we change the status of a task, update tasks this tasks blocks // with a message to the effect of "alincoln resolved blocking task Txxx." $unblock_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTaskStatusTransaction::TRANSACTIONTYPE: $unblock_xaction = $xaction; break; } } if ($unblock_xaction !== null) { $blocked_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), ManiphestTaskDependedOnByTaskEdgeType::EDGECONST); if ($blocked_phids) { // In theory we could apply these through policies, but that seems a // little bit surprising. For now, use the actor's vision. $blocked_tasks = id(new ManiphestTaskQuery()) ->setViewer($this->getActor()) ->withPHIDs($blocked_phids) ->needSubscriberPHIDs(true) ->needProjectPHIDs(true) ->execute(); $old = $unblock_xaction->getOldValue(); $new = $unblock_xaction->getNewValue(); foreach ($blocked_tasks as $blocked_task) { $parent_xaction = id(new ManiphestTransaction()) ->setTransactionType( ManiphestTaskUnblockTransaction::TRANSACTIONTYPE) ->setOldValue(array($object->getPHID() => $old)) ->setNewValue(array($object->getPHID() => $new)); if ($this->getIsNewObject()) { $parent_xaction->setMetadataValue('blocker.new', true); } $this->newSubEditor() ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($blocked_task, array($parent_xaction)); } } } return $xactions; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailSubjectPrefix() { return pht('[Maniphest]'); } protected function getMailThreadID(PhabricatorLiskDAO $object) { return 'maniphest-task-'.$object->getPHID(); } protected function getMailTo(PhabricatorLiskDAO $object) { $phids = array(); if ($object->getOwnerPHID()) { $phids[] = $object->getOwnerPHID(); } $phids[] = $this->getActingAsPHID(); return $phids; } public function getMailTagsMap() { return array( ManiphestTransaction::MAILTAG_STATUS => pht("A task's status changes."), ManiphestTransaction::MAILTAG_OWNER => pht("A task's owner changes."), ManiphestTransaction::MAILTAG_PRIORITY => pht("A task's priority changes."), ManiphestTransaction::MAILTAG_CC => pht("A task's subscribers change."), ManiphestTransaction::MAILTAG_PROJECTS => pht("A task's associated projects change."), ManiphestTransaction::MAILTAG_UNBLOCK => pht("One of a task's subtasks changes status."), ManiphestTransaction::MAILTAG_COLUMN => pht('A task is moved between columns on a workboard.'), ManiphestTransaction::MAILTAG_COMMENT => pht('Someone comments on a task.'), ManiphestTransaction::MAILTAG_OTHER => pht('Other task activity not listed above occurs.'), ); } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new ManiphestReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $title = $object->getTitle(); return id(new PhabricatorMetaMTAMail()) ->setSubject("T{$id}: {$title}"); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); if ($this->getIsNewObject()) { $body->addRemarkupSection( pht('TASK DESCRIPTION'), $object->getDescription()); } $body->addLinkSection( pht('TASK DETAIL'), PhabricatorEnv::getProductionURI('/T'.$object->getID())); $board_phids = array(); $type_columns = PhabricatorTransactions::TYPE_COLUMNS; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_columns) { $moves = $xaction->getNewValue(); foreach ($moves as $move) { $board_phids[] = $move['boardPHID']; } } } if ($board_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs($board_phids) ->execute(); foreach ($projects as $project) { $body->addLinkSection( pht('WORKBOARD'), - PhabricatorEnv::getProductionURI( - '/project/board/'.$project->getID().'/')); + PhabricatorEnv::getProductionURI($project->getWorkboardURI())); } } return $body; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function supportsSearch() { return true; } protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { return id(new HeraldManiphestTaskAdapter()) ->setTask($object); } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = parent::adjustObjectForPolicyChecks($object, $xactions); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE: $copy->setOwnerPHID($xaction->getNewValue()); break; default: break; } } return $copy; } protected function validateAllTransactions( PhabricatorLiskDAO $object, array $xactions) { $errors = parent::validateAllTransactions($object, $xactions); if ($this->moreValidationErrors) { $errors = array_merge($errors, $this->moreValidationErrors); } foreach ($this->getLockValidationErrors($object, $xactions) as $error) { $errors[] = $error; } return $errors; } protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $actor = $this->getActor(); $actor_phid = $actor->getPHID(); $results = parent::expandTransactions($object, $xactions); $is_unassigned = ($object->getOwnerPHID() === null); $any_assign = false; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == ManiphestTaskOwnerTransaction::TRANSACTIONTYPE) { $any_assign = true; break; } } $is_open = !$object->isClosed(); $new_status = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTaskStatusTransaction::TRANSACTIONTYPE: $new_status = $xaction->getNewValue(); break; } } if ($new_status === null) { $is_closing = false; } else { $is_closing = ManiphestTaskStatus::isClosedStatus($new_status); } // If the task is not assigned, not being assigned, currently open, and // being closed, try to assign the actor as the owner. if ($is_unassigned && !$any_assign && $is_open && $is_closing) { $is_claim = ManiphestTaskStatus::isClaimStatus($new_status); // Don't assign the actor if they aren't a real user. // Don't claim the task if the status is configured to not claim. if ($actor_phid && $is_claim) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTaskOwnerTransaction::TRANSACTIONTYPE) ->setNewValue($actor_phid); } } // Automatically subscribe the author when they create a task. if ($this->getIsNewObject()) { if ($actor_phid) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue( array( '+' => array($actor_phid => $actor_phid), )); } } return $results; } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $results = parent::expandTransaction($object, $xaction); $type = $xaction->getTransactionType(); switch ($type) { case PhabricatorTransactions::TYPE_COLUMNS: try { $more_xactions = $this->buildMoveTransaction($object, $xaction); foreach ($more_xactions as $more_xaction) { $results[] = $more_xaction; } } catch (Exception $ex) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), $ex->getMessage(), $xaction); $this->moreValidationErrors[] = $error; } break; case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE: // If this is a no-op update, don't expand it. $old_value = $object->getOwnerPHID(); $new_value = $xaction->getNewValue(); if ($old_value === $new_value) { break; } // When a task is reassigned, move the old owner to the subscriber // list so they're still in the loop. if ($old_value) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setIgnoreOnNoEffect(true) ->setNewValue( array( '+' => array($old_value => $old_value), )); } break; } return $results; } private function buildMoveTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $actor = $this->getActor(); $new = $xaction->getNewValue(); if (!is_array($new)) { $this->validateColumnPHID($new); $new = array($new); } $relative_phids = array(); foreach ($new as $key => $value) { if (!is_array($value)) { $this->validateColumnPHID($value); $value = array( 'columnPHID' => $value, ); } PhutilTypeSpec::checkMap( $value, array( 'columnPHID' => 'string', 'beforePHIDs' => 'optional list', 'afterPHIDs' => 'optional list', // Deprecated older variations of "beforePHIDs" and "afterPHIDs". 'beforePHID' => 'optional string', 'afterPHID' => 'optional string', )); $value = $value + array( 'beforePHIDs' => array(), 'afterPHIDs' => array(), ); // Normalize the legacy keys "beforePHID" and "afterPHID" keys to the // modern format. if (!empty($value['afterPHID'])) { if ($value['afterPHIDs']) { throw new Exception( pht( 'Transaction specifies both "afterPHID" and "afterPHIDs". '. 'Specify only "afterPHIDs".')); } $value['afterPHIDs'] = array($value['afterPHID']); unset($value['afterPHID']); } if (isset($value['beforePHID'])) { if ($value['beforePHIDs']) { throw new Exception( pht( 'Transaction specifies both "beforePHID" and "beforePHIDs". '. 'Specify only "beforePHIDs".')); } $value['beforePHIDs'] = array($value['beforePHID']); unset($value['beforePHID']); } foreach ($value['beforePHIDs'] as $phid) { $relative_phids[] = $phid; } foreach ($value['afterPHIDs'] as $phid) { $relative_phids[] = $phid; } $new[$key] = $value; } // We require that objects you specify in "beforePHIDs" or "afterPHIDs" // are real objects which exist and which you have permission to view. // If you provide other objects, we remove them from the specification. if ($relative_phids) { $objects = id(new PhabricatorObjectQuery()) ->setViewer($actor) ->withPHIDs($relative_phids) ->execute(); $objects = mpull($objects, null, 'getPHID'); } else { $objects = array(); } foreach ($new as $key => $value) { $value['afterPHIDs'] = $this->filterValidPHIDs( $value['afterPHIDs'], $objects); $value['beforePHIDs'] = $this->filterValidPHIDs( $value['beforePHIDs'], $objects); $new[$key] = $value; } $column_phids = ipull($new, 'columnPHID'); if ($column_phids) { $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($actor) ->withPHIDs($column_phids) ->execute(); $columns = mpull($columns, null, 'getPHID'); } else { $columns = array(); } $board_phids = mpull($columns, 'getProjectPHID'); $object_phid = $object->getPHID(); // Note that we may not have an object PHID if we're creating a new // object. $object_phids = array(); if ($object_phid) { $object_phids[] = $object_phid; } if ($object_phids) { $layout_engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($this->getActor()) ->setBoardPHIDs($board_phids) ->setObjectPHIDs($object_phids) ->setFetchAllBoards(true) ->executeLayout(); } foreach ($new as $key => $spec) { $column_phid = $spec['columnPHID']; $column = idx($columns, $column_phid); if (!$column) { throw new Exception( pht( 'Column move transaction specifies column PHID "%s", but there '. 'is no corresponding column with this PHID.', $column_phid)); } $board_phid = $column->getProjectPHID(); if ($object_phid) { $old_columns = $layout_engine->getObjectColumns( $board_phid, $object_phid); $old_column_phids = mpull($old_columns, 'getPHID'); } else { $old_column_phids = array(); } $spec += array( 'boardPHID' => $board_phid, 'fromColumnPHIDs' => $old_column_phids, ); // Check if the object is already in this column, and isn't being moved. // We can just drop this column change if it has no effect. $from_map = array_fuse($spec['fromColumnPHIDs']); $already_here = isset($from_map[$column_phid]); $is_reordering = ($spec['afterPHIDs'] || $spec['beforePHIDs']); if ($already_here && !$is_reordering) { unset($new[$key]); } else { $new[$key] = $spec; } } $new = array_values($new); $xaction->setNewValue($new); $more = array(); // If we're moving the object into a column and it does not already belong // in the column, add the appropriate board. For normal columns, this // is the board PHID. For proxy columns, it is the proxy PHID, unless the // object is already a member of some descendant of the proxy PHID. // The major case where this can happen is moves via the API, but it also // happens when a user drags a task from the "Backlog" to a milestone // column. if ($object_phid) { $current_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object_phid, PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); $current_phids = array_fuse($current_phids); } else { $current_phids = array(); } $add_boards = array(); foreach ($new as $move) { $column_phid = $move['columnPHID']; $board_phid = $move['boardPHID']; $column = $columns[$column_phid]; $proxy_phid = $column->getProxyPHID(); // If this is a normal column, add the board if the object isn't already // associated. if (!$proxy_phid) { if (!isset($current_phids[$board_phid])) { $add_boards[] = $board_phid; } continue; } // If this is a proxy column but the object is already associated with // the proxy board, we don't need to do anything. if (isset($current_phids[$proxy_phid])) { continue; } // If this a proxy column and the object is already associated with some // descendant of the proxy board, we also don't need to do anything. $descendants = id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withAncestorProjectPHIDs(array($proxy_phid)) ->execute(); $found_descendant = false; foreach ($descendants as $descendant) { if (isset($current_phids[$descendant->getPHID()])) { $found_descendant = true; break; } } if ($found_descendant) { continue; } // Otherwise, we're moving the object to a proxy column which it is not // a member of yet, so add an association to the column's proxy board. $add_boards[] = $proxy_phid; } if ($add_boards) { $more[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue( 'edge:type', PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) ->setIgnoreOnNoEffect(true) ->setNewValue( array( '+' => array_fuse($add_boards), )); } return $more; } private function applyBoardMove($object, array $move) { $board_phid = $move['boardPHID']; $column_phid = $move['columnPHID']; $before_phids = $move['beforePHIDs']; $after_phids = $move['afterPHIDs']; $object_phid = $object->getPHID(); // We're doing layout with the omnipotent viewer to make sure we don't // remove positions in columns that exist, but which the actual actor // 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_ANCESTOR, array($select_phids)) ->execute(); $board_tasks = mpull($board_tasks, null, 'getPHID'); $board_tasks[$object_phid] = $object; // Make sure tasks are sorted by ID, so we lay out new positions in // a consistent way. $board_tasks = msort($board_tasks, 'getID'); $object_phids = array_keys($board_tasks); $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($omnipotent_viewer) ->setBoardPHIDs(array($board_phid)) ->setObjectPHIDs($object_phids) ->executeLayout(); // TODO: This logic needs to be revised when we legitimately support // multiple column positions. $columns = $engine->getObjectColumns($board_phid, $object_phid); foreach ($columns as $column) { $engine->queueRemovePosition( $board_phid, $column->getPHID(), $object_phid); } $engine->queueAddPosition( $board_phid, $column_phid, $object_phid, $after_phids, $before_phids); $engine->applyPositionUpdates(); } private function validateColumnPHID($value) { if (phid_get_type($value) == PhabricatorProjectColumnPHIDType::TYPECONST) { return; } throw new Exception( pht( 'When moving objects between columns on a board, columns must '. 'be identified by PHIDs. This transaction uses "%s" to identify '. 'a column, but that is not a valid column PHID.', $value)); } private function getLockValidationErrors($object, array $xactions) { $errors = array(); $old_owner = $object->getOwnerPHID(); $old_status = $object->getStatus(); $new_owner = $old_owner; $new_status = $old_status; $owner_xaction = null; $status_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE: $new_owner = $xaction->getNewValue(); $owner_xaction = $xaction; break; case ManiphestTaskStatusTransaction::TRANSACTIONTYPE: $new_status = $xaction->getNewValue(); $status_xaction = $xaction; break; } } $actor_phid = $this->getActingAsPHID(); $was_locked = ManiphestTaskStatus::areEditsLockedInStatus( $old_status); $now_locked = ManiphestTaskStatus::areEditsLockedInStatus( $new_status); if (!$now_locked) { // If we're not ending in an edit-locked status, everything is good. } else if ($new_owner !== null) { // If we ending the edit with some valid owner, this is allowed for // now. We might need to revisit this. } else { // The edits end with the task locked and unowned. No one will be able // to edit it, so we forbid this. We try to be specific about what the // user did wrong. $owner_changed = ($old_owner && !$new_owner); $status_changed = ($was_locked !== $now_locked); $message = null; if ($status_changed && $owner_changed) { $message = pht( 'You can not lock this task and unassign it at the same time '. 'because no one will be able to edit it anymore. Lock the task '. 'or remove the owner, but not both.'); $problem_xaction = $status_xaction; } else if ($status_changed) { $message = pht( 'You can not lock this task because it does not have an owner. '. 'No one would be able to edit the task. Assign the task to an '. 'owner before locking it.'); $problem_xaction = $status_xaction; } else if ($owner_changed) { $message = pht( 'You can not remove the owner of this task because it is locked '. 'and no one would be able to edit the task. Reassign the task or '. 'unlock it before removing the owner.'); $problem_xaction = $owner_xaction; } else { // If the task was already broken, we don't have a transaction to // complain about so just let it through. In theory, this is // impossible since policy rules should kick in before we get here. } if ($message) { $errors[] = new PhabricatorApplicationTransactionValidationError( $problem_xaction->getTransactionType(), pht('Lock Error'), $message, $problem_xaction); } } return $errors; } private function filterValidPHIDs($phid_list, array $object_map) { foreach ($phid_list as $key => $phid) { if (isset($object_map[$phid])) { continue; } unset($phid_list[$key]); } return array_values($phid_list); } } diff --git a/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php b/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php index b229f59ecb..c70c211398 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php @@ -1,170 +1,170 @@ getUser(); $board_id = $request->getURIData('projectID'); $board = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withIDs(array($board_id)) ->needImages(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$board) { return new Aphront404Response(); } if (!$board->getHasWorkboard()) { return new Aphront404Response(); } $this->setProject($board); $id = $board->getID(); $view_uri = $this->getApplicationURI("board/{$id}/"); $manage_uri = $this->getApplicationURI("board/{$id}/manage/"); if ($request->isFormPost()) { $background_key = $request->getStr('backgroundKey'); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType( PhabricatorProjectWorkboardBackgroundTransaction::TRANSACTIONTYPE) ->setNewValue($background_key); id(new PhabricatorProjectTransactionEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($board, $xactions); return id(new AphrontRedirectResponse()) ->setURI($view_uri); } $nav = $this->getProfileMenu(); $crumbs = id($this->buildApplicationCrumbs()) - ->addTextCrumb(pht('Workboard'), "/project/board/{$board_id}/") + ->addTextCrumb(pht('Workboard'), $board->getWorkboardURI()) ->addTextCrumb(pht('Manage'), $manage_uri) ->addTextCrumb(pht('Background Color')); $form = id(new AphrontFormView()) ->setUser($viewer); $group_info = array( 'basic' => array( 'label' => pht('Basics'), ), 'solid' => array( 'label' => pht('Solid Colors'), ), 'gradient' => array( 'label' => pht('Gradients'), ), ); $groups = array(); $options = PhabricatorProjectWorkboardBackgroundColor::getOptions(); $option_groups = igroup($options, 'group'); require_celerity_resource('people-profile-css'); require_celerity_resource('phui-workboard-color-css'); Javelin::initBehavior('phabricator-tooltips', array()); foreach ($group_info as $group_key => $spec) { $buttons = array(); $available_options = idx($option_groups, $group_key, array()); foreach ($available_options as $option) { $buttons[] = $this->renderOptionButton($option); } $form->appendControl( id(new AphrontFormMarkupControl()) ->setLabel($spec['label']) ->setValue($buttons)); } // NOTE: Each button is its own form, so we can't wrap them in a normal // form. $layout_view = $form->buildLayoutView(); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Edit Background Color')) ->appendChild($layout_view); return $this->newPage() ->setTitle( array( pht('Edit Background Color'), $board->getDisplayName(), )) ->setCrumbs($crumbs) ->setNavigation($nav) ->appendChild($form_box); } private function renderOptionButton(array $option) { $viewer = $this->getViewer(); $icon = idx($option, 'icon'); if ($icon) { $preview_class = null; $preview_content = id(new PHUIIconView()) ->setIcon($icon, 'lightbluetext'); } else { $preview_class = 'phui-workboard-'.$option['key']; $preview_content = null; } $preview = phutil_tag( 'div', array( 'class' => 'phui-workboard-color-preview '.$preview_class, ), $preview_content); $button = javelin_tag( 'button', array( 'class' => 'button-grey profile-image-button', 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => $option['name'], 'size' => 300, ), ), $preview); $input = phutil_tag( 'input', array( 'type' => 'hidden', 'name' => 'backgroundKey', 'value' => $option['key'], )); return phabricator_form( $viewer, array( 'class' => 'profile-image-form', 'method' => 'POST', ), array( $button, $input, )); } } diff --git a/src/applications/project/controller/PhabricatorProjectBoardManageController.php b/src/applications/project/controller/PhabricatorProjectBoardManageController.php index 5c71dcfb61..21daf2e654 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardManageController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardManageController.php @@ -1,142 +1,142 @@ getViewer(); $board_id = $request->getURIData('projectID'); $board = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withIDs(array($board_id)) ->needImages(true) ->executeOne(); if (!$board) { return new Aphront404Response(); } $this->setProject($board); // Perform layout of no tasks to load and populate the columns in the // correct order. $layout_engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($board->getPHID())) ->setObjectPHIDs(array()) ->setFetchAllBoards(true) ->executeLayout(); $columns = $layout_engine->getColumns($board->getPHID()); $board_id = $board->getID(); $header = $this->buildHeaderView($board); $curtain = $this->buildCurtainView($board); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Workboard'), "/project/board/{$board_id}/"); + $crumbs->addTextCrumb(pht('Workboard'), $board->getWorkboardURI()); $crumbs->addTextCrumb(pht('Manage')); $crumbs->setBorder(true); $nav = $this->getProfileMenu(); $columns_list = $this->buildColumnsList($board, $columns); require_celerity_resource('project-view-css'); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->addClass('project-view-home') ->addClass('project-view-people-home') ->setCurtain($curtain) ->setMainColumn($columns_list); $title = array( pht('Manage Workboard'), $board->getDisplayName(), ); return $this->newPage() ->setTitle($title) ->setNavigation($nav) ->setCrumbs($crumbs) ->appendChild($view); } private function buildHeaderView(PhabricatorProject $board) { $viewer = $this->getViewer(); $header = id(new PHUIHeaderView()) ->setHeader(pht('Workboard: %s', $board->getDisplayName())) ->setUser($viewer); return $header; } private function buildCurtainView(PhabricatorProject $board) { $viewer = $this->getViewer(); $id = $board->getID(); $curtain = $this->newCurtainView(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $board, PhabricatorPolicyCapability::CAN_EDIT); $disable_uri = $this->getApplicationURI("board/{$id}/disable/"); $curtain->addAction( id(new PhabricatorActionView()) ->setIcon('fa-ban') ->setName(pht('Disable Workboard')) ->setHref($disable_uri) ->setDisabled(!$can_edit) ->setWorkflow(true)); return $curtain; } private function buildColumnsList( PhabricatorProject $board, array $columns) { assert_instances_of($columns, 'PhabricatorProjectColumn'); $board_id = $board->getID(); $view = id(new PHUIObjectItemListView()) ->setNoDataString(pht('This board has no columns.')); foreach ($columns as $column) { $column_id = $column->getID(); $proxy = $column->getProxy(); if ($proxy && !$proxy->isMilestone()) { continue; } $detail_uri = "/project/board/{$board_id}/column/{$column_id}/"; $item = id(new PHUIObjectItemView()) ->setHeader($column->getDisplayName()) ->setHref($detail_uri); if ($column->isHidden()) { $item->setDisabled(true); $item->addAttribute(pht('Hidden')); $item->setImageIcon('fa-columns grey'); } else { $item->addAttribute(pht('Visible')); $item->setImageIcon('fa-columns'); } $view->addItem($item); } return id(new PHUIObjectBoxView()) ->setHeaderText(pht('Columns')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList($view); } } diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index e8a47d362a..775ff1b61a 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -1,1507 +1,1507 @@ 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') && !$request->getStr('move') && !$request->getStr('queryColumnID')) { $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 = $this->getDefaultFilter($project); $request_query = $request->getStr('filter'); if (strlen($request_query)) { $query_key = $request_query; } $uri_query = $request->getURIData('queryKey'); if (strlen($uri_query)) { $query_key = $uri_query; } $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(); // Regardless of display order, pass tasks to the layout engine in ID order // so layout is consistent. $board_tasks = msort($tasks, 'getID'); $layout_engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($board_phid)) ->setObjectPHIDs(array_keys($board_tasks)) ->setFetchAllBoards(true) ->executeLayout(); $columns = $layout_engine->getColumns($board_phid); if (!$columns || !$project->getHasWorkboard()) { $has_normal_columns = false; foreach ($columns as $column) { if (!$column->getProxyPHID()) { $has_normal_columns = true; break; } } $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); if (!$has_normal_columns) { if (!$can_edit) { $content = $this->buildNoAccessContent($project); } else { $content = $this->buildInitializeContent($project); } } else { if (!$can_edit) { $content = $this->buildDisabledContent($project); } else { $content = $this->buildEnableContent($project); } } if ($content instanceof AphrontResponse) { return $content; } $nav = $this->getProfileMenu(); $nav->selectFilter(PhabricatorProject::ITEM_WORKBOARD); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Workboard')); return $this->newPage() ->setTitle( array( $project->getDisplayName(), pht('Workboard'), )) ->setNavigation($nav) ->setCrumbs($crumbs) ->appendChild($content); } // If the user wants to turn a particular column into a query, build an // apropriate filter and redirect them to the query results page. $query_column_id = $request->getInt('queryColumnID'); if ($query_column_id) { $column_id_map = mpull($columns, null, 'getID'); $query_column = idx($column_id_map, $query_column_id); if (!$query_column) { return new Aphront404Response(); } // Create a saved query to combine the active filter on the workboard // with the column filter. If the user currently has constraints on the // board, we want to add a new column or project constraint, not // completely replace the constraints. $saved_query = $saved->newCopy(); if ($query_column->getProxyPHID()) { $project_phids = $saved_query->getParameter('projectPHIDs'); if (!$project_phids) { $project_phids = array(); } $project_phids[] = $query_column->getProxyPHID(); $saved_query->setParameter('projectPHIDs', $project_phids); } else { $saved_query->setParameter( 'columnPHIDs', array($query_column->getPHID())); } $search_engine = id(new ManiphestTaskSearchEngine()) ->setViewer($viewer); $search_engine->saveQuery($saved_query); $query_key = $saved_query->getQueryKey(); $query_uri = new PhutilURI("/maniphest/query/{$query_key}/#R"); return id(new AphrontRedirectResponse()) ->setURI($query_uri); } $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); } // Create a saved query to hold the working set. This allows us to get // around URI length limitations with a long "?ids=..." query string. // For details, see T10268. $search_engine = id(new ManiphestTaskSearchEngine()) ->setViewer($viewer); $saved_query = $search_engine->newSavedQuery(); $saved_query->setParameter('ids', mpull($batch_tasks, 'getID')); $search_engine->saveQuery($saved_query); $query_key = $saved_query->getQueryKey(); $bulk_uri = new PhutilURI("/maniphest/bulk/query/{$query_key}/"); $bulk_uri->replaceQueryParam('board', $this->id); return id(new AphrontRedirectResponse()) ->setURI($bulk_uri); } $move_id = $request->getStr('move'); if (strlen($move_id)) { $column_id_map = mpull($columns, null, 'getID'); $move_column = idx($column_id_map, $move_id); if (!$move_column) { return new Aphront404Response(); } $move_task_phids = $layout_engine->getColumnObjectPHIDs( $board_phid, $move_column->getPHID()); foreach ($move_task_phids as $key => $move_task_phid) { if (empty($task_can_edit_map[$move_task_phid])) { unset($move_task_phids[$key]); } } $move_tasks = array_select_keys($tasks, $move_task_phids); $cancel_uri = $this->getURIWithState($board_uri); if (!$move_tasks) { return $this->newDialog() ->setTitle(pht('No Movable Tasks')) ->appendParagraph( pht( 'The selected column contains no visible tasks which you '. 'have permission to move.')) ->addCancelButton($cancel_uri); } $move_project_phid = $project->getPHID(); $move_column_phid = null; $move_project = null; $move_column = null; $columns = null; $errors = array(); if ($request->isFormOrHiSecPost()) { $move_project_phid = head($request->getArr('moveProjectPHID')); if (!$move_project_phid) { $move_project_phid = $request->getStr('moveProjectPHID'); } if (!$move_project_phid) { if ($request->getBool('hasProject')) { $errors[] = pht('Choose a project to move tasks to.'); } } else { $target_project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withPHIDs(array($move_project_phid)) ->executeOne(); if (!$target_project) { $errors[] = pht('You must choose a valid project.'); } else if (!$project->getHasWorkboard()) { $errors[] = pht( 'You must choose a project with a workboard.'); } else { $move_project = $target_project; } } if ($move_project) { $move_engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($move_project->getPHID())) ->setFetchAllBoards(true) ->executeLayout(); $columns = $move_engine->getColumns($move_project->getPHID()); $columns = mpull($columns, null, 'getPHID'); foreach ($columns as $key => $column) { if ($column->isHidden()) { unset($columns[$key]); } } $move_column_phid = $request->getStr('moveColumnPHID'); if (!$move_column_phid) { if ($request->getBool('hasColumn')) { $errors[] = pht('Choose a column to move tasks to.'); } } else { if (empty($columns[$move_column_phid])) { $errors[] = pht( 'Choose a valid column on the target workboard to move '. 'tasks to.'); } else if ($columns[$move_column_phid]->getID() == $move_id) { $errors[] = pht( 'You can not move tasks from a column to itself.'); } else { $move_column = $columns[$move_column_phid]; } } } } if ($move_column && $move_project) { foreach ($move_tasks as $move_task) { $xactions = array(); // If we're switching projects, get out of the old project first // and move to the new project. if ($move_project->getID() != $project->getID()) { $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue( 'edge:type', PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) ->setNewValue( array( '-' => array( $project->getPHID() => $project->getPHID(), ), '+' => array( $move_project->getPHID() => $move_project->getPHID(), ), )); } $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COLUMNS) ->setNewValue( array( array( 'columnPHID' => $move_column->getPHID(), ), )); $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) ->setContinueOnMissingFields(true) ->setContinueOnNoEffect(true) ->setContentSourceFromRequest($request) ->setCancelURI($cancel_uri); $editor->applyTransactions($move_task, $xactions); } return id(new AphrontRedirectResponse()) ->setURI($cancel_uri); } if ($move_project) { $column_form = id(new AphrontFormView()) ->setViewer($viewer) ->appendControl( id(new AphrontFormSelectControl()) ->setName('moveColumnPHID') ->setLabel(pht('Move to Column')) ->setValue($move_column_phid) ->setOptions(mpull($columns, 'getDisplayName', 'getPHID'))); return $this->newDialog() ->setTitle(pht('Move Tasks')) ->setWidth(AphrontDialogView::WIDTH_FORM) ->setErrors($errors) ->addHiddenInput('move', $move_id) ->addHiddenInput('moveProjectPHID', $move_project->getPHID()) ->addHiddenInput('hasColumn', true) ->addHiddenInput('hasProject', true) ->appendParagraph( pht( 'Choose a column on the %s workboard to move tasks to:', $viewer->renderHandle($move_project->getPHID()))) ->appendForm($column_form) ->addSubmitButton(pht('Move Tasks')) ->addCancelButton($cancel_uri); } if ($move_project_phid) { $move_project_phid_value = array($move_project_phid); } else { $move_project_phid_value = array(); } $project_form = id(new AphrontFormView()) ->setViewer($viewer) ->appendControl( id(new AphrontFormTokenizerControl()) ->setName('moveProjectPHID') ->setLimit(1) ->setLabel(pht('Move to Project')) ->setValue($move_project_phid_value) ->setDatasource(new PhabricatorProjectDatasource())); return $this->newDialog() ->setTitle(pht('Move Tasks')) ->setWidth(AphrontDialogView::WIDTH_FORM) ->setErrors($errors) ->addHiddenInput('move', $move_id) ->addHiddenInput('hasProject', true) ->appendForm($project_form) ->addSubmitButton(pht('Continue')) ->addCancelButton($cancel_uri); } $board_id = celerity_generate_unique_node_id(); $board = id(new PHUIWorkboardView()) ->setUser($viewer) ->setID($board_id) ->addSigil('jx-workboard') ->setMetadata( array( 'boardPHID' => $project->getPHID(), )); $visible_columns = array(); $column_phids = array(); $visible_phids = array(); 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); $column_phid = $column->getPHID(); $visible_columns[$column_phid] = $column; $column_phids[$column_phid] = $column_tasks; foreach ($column_tasks as $phid => $task) { $visible_phids[$phid] = $phid; } } $rendering_engine = id(new PhabricatorBoardRenderingEngine()) ->setViewer($viewer) ->setObjects(array_select_keys($tasks, $visible_phids)) ->setEditMap($task_can_edit_map) ->setExcludedProjectPHIDs($select_phids); $templates = array(); $all_tasks = array(); $column_templates = array(); $sounds = array(); foreach ($visible_columns as $column_phid => $column) { $column_tasks = $column_phids[$column_phid]; $panel = id(new PHUIWorkpanelView()) ->setHeader($column->getDisplayName()) ->setSubHeader($column->getDisplayType()) ->addSigil('workpanel'); $proxy = $column->getProxy(); if ($proxy) { $proxy_id = $proxy->getID(); $href = $this->getApplicationURI("view/{$proxy_id}/"); $panel->setHref($href); } $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); if ($column->canHaveTrigger()) { $trigger_menu = $this->buildTriggerMenu($column); $panel->addHeaderAction($trigger_menu); } $count_tag = id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setColor(PHUITagView::COLOR_BLUE) ->addSigil('column-points') ->setName( javelin_tag( 'span', array( 'sigil' => 'column-points-content', ), pht('-'))) ->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(), 'pointLimit' => $column->getPointLimit(), )); $card_phids = array(); foreach ($column_tasks as $task) { $object_phid = $task->getPHID(); $card = $rendering_engine->renderCard($object_phid); $templates[$object_phid] = hsprintf('%s', $card->getItem()); $card_phids[] = $object_phid; $all_tasks[$object_phid] = $task; } $panel->setCards($cards); $board->addPanel($panel); $drop_effects = $column->getDropEffects(); $drop_effects = mpull($drop_effects, 'toDictionary'); $preview_effect = null; if ($column->canHaveTrigger()) { $trigger = $column->getTrigger(); if ($trigger) { $preview_effect = $trigger->getPreviewEffect() ->toDictionary(); foreach ($trigger->getSoundEffects() as $sound) { $sounds[] = $sound; } } } $column_templates[] = array( 'columnPHID' => $column_phid, 'effects' => $drop_effects, 'cardPHIDs' => $card_phids, 'triggerPreviewEffect' => $preview_effect, ); } $order_key = $this->sortKey; $ordering_map = PhabricatorProjectColumnOrder::getEnabledOrders(); $ordering = id(clone $ordering_map[$order_key]) ->setViewer($viewer); $headers = $ordering->getHeadersForObjects($all_tasks); $headers = mpull($headers, 'toDictionary'); $vectors = $ordering->getSortVectorsForObjects($all_tasks); $vector_map = array(); foreach ($vectors as $task_phid => $vector) { $vector_map[$task_phid][$order_key] = $vector; } $header_keys = $ordering->getHeaderKeysForObjects($all_tasks); $order_maps = array(); $order_maps[] = $ordering->toDictionary(); $properties = array(); foreach ($all_tasks as $task) { $properties[$task->getPHID()] = PhabricatorBoardResponseEngine::newTaskProperties($task); } $behavior_config = array( 'moveURI' => $this->getApplicationURI('move/'.$project->getID().'/'), 'uploadURI' => '/file/dropupload/', 'coverURI' => $this->getApplicationURI('cover/'), 'chunkThreshold' => PhabricatorFileStorageEngine::getChunkThreshold(), 'pointsEnabled' => ManiphestTaskPoints::getIsEnabled(), 'boardPHID' => $project->getPHID(), 'order' => $this->sortKey, 'orders' => $order_maps, 'headers' => $headers, 'headerKeys' => $header_keys, 'templateMap' => $templates, 'orderMaps' => $vector_map, 'propertyMaps' => $properties, 'columnTemplates' => $column_templates, 'boardID' => $board_id, 'projectPHID' => $project->getPHID(), 'preloadSounds' => $sounds, ); $this->initBehavior('project-boards', $behavior_config); $sort_menu = $this->buildSortMenu( $viewer, $project, $this->sortKey, $ordering_map); $filter_menu = $this->buildFilterMenu( $viewer, $project, $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(); $divider = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_DIVIDER); $fullscreen = $this->buildFullscreenMenu(); - $crumbs = $this->buildApplicationCrumbs(); + $crumbs = $this->newWorkboardCrumbs(); $crumbs->addTextCrumb(pht('Workboard')); $crumbs->setBorder(true); $crumbs->addAction($sort_menu); $crumbs->addAction($filter_menu); $crumbs->addAction($divider); $crumbs->addAction($manage_menu); $crumbs->addAction($fullscreen); $page = $this->newPage() ->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, )); $background = $project->getDisplayWorkboardBackgroundColor(); require_celerity_resource('phui-workboard-color-css'); if ($background !== null) { $background_color_class = "phui-workboard-{$background}"; $page->addClass('phui-workboard-color'); $page->addClass($background_color_class); } else { $page->addClass('phui-workboard-no-color'); } return $page; } private function readRequestState() { $request = $this->getRequest(); $project = $this->getProject(); $this->showHidden = $request->getBool('hidden'); $this->id = $project->getID(); $sort_key = $this->getDefaultSort($project); $request_sort = $request->getStr('order'); if ($this->isValidSort($request_sort)) { $sort_key = $request_sort; } $this->sortKey = $sort_key; } private function getDefaultSort(PhabricatorProject $project) { $default_sort = $project->getDefaultWorkboardSort(); if ($this->isValidSort($default_sort)) { return $default_sort; } return PhabricatorProjectColumnNaturalOrder::ORDERKEY; } private function getDefaultFilter(PhabricatorProject $project) { $default_filter = $project->getDefaultWorkboardFilter(); if (strlen($default_filter)) { return $default_filter; } return 'open'; } private function isValidSort($sort) { $map = PhabricatorProjectColumnOrder::getEnabledOrders(); return isset($map[$sort]); } private function buildSortMenu( PhabricatorUser $viewer, PhabricatorProject $project, $sort_key, array $ordering_map) { $base_uri = $this->getURIWithState(); $items = array(); foreach ($ordering_map as $key => $ordering) { // TODO: It would be desirable to build a real "PHUIIconView" here, but // the pathway for threading that through all the view classes ends up // being fairly complex, since some callers read the icon out of other // views. For now, just stick with a string. $ordering_icon = $ordering->getMenuIconIcon(); $ordering_name = $ordering->getDisplayName(); $is_selected = ($key === $sort_key); if ($is_selected) { $active_name = $ordering_name; $active_icon = $ordering_icon; } $item = id(new PhabricatorActionView()) ->setIcon($ordering_icon) ->setSelected($is_selected) ->setName($ordering_name); $uri = $base_uri->alter('order', $key); $item->setHref($uri); $items[] = $item; } $id = $project->getID(); $save_uri = "default/{$id}/sort/"; $save_uri = $this->getApplicationURI($save_uri); $save_uri = $this->getURIWithState($save_uri, $force = true); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $items[] = id(new PhabricatorActionView()) ->setType(PhabricatorActionView::TYPE_DIVIDER); $items[] = id(new PhabricatorActionView()) ->setIcon('fa-floppy-o') ->setName(pht('Save as Default')) ->setHref($save_uri) ->setWorkflow(true) ->setDisabled(!$can_edit); $sort_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($items as $item) { $sort_menu->addAction($item); } $sort_button = id(new PHUIListItemView()) ->setName($active_name) ->setIcon($active_icon) ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $sort_menu), )); return $sort_button; } private function buildFilterMenu( PhabricatorUser $viewer, PhabricatorProject $project, $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) ->removeQueryParam('filter'); $item->setHref($uri); $items[] = $item; } $id = $project->getID(); $filter_uri = $this->getApplicationURI("board/{$id}/filter/"); $filter_uri = $this->getURIWithState($filter_uri, $force = true); $items[] = id(new PhabricatorActionView()) ->setIcon('fa-cog') ->setHref($filter_uri) ->setWorkflow(true) ->setName(pht('Advanced Filter...')); $save_uri = "default/{$id}/filter/"; $save_uri = $this->getApplicationURI($save_uri); $save_uri = $this->getURIWithState($save_uri, $force = true); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $items[] = id(new PhabricatorActionView()) ->setType(PhabricatorActionView::TYPE_DIVIDER); $items[] = id(new PhabricatorActionView()) ->setIcon('fa-floppy-o') ->setName(pht('Save as Default')) ->setHref($save_uri) ->setWorkflow(true) ->setDisabled(!$can_edit); $filter_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($items as $item) { $filter_menu->addAction($item); } $filter_button = id(new PHUIListItemView()) ->setName($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(); $id = $project->getID(); $manage_uri = $this->getApplicationURI("board/{$id}/manage/"); $add_uri = $this->getApplicationURI("board/{$id}/edit/"); $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($add_uri) ->setDisabled(!$can_edit) ->setWorkflow(true); $reorder_uri = $this->getApplicationURI("board/{$id}/reorder/"); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-exchange') ->setName(pht('Reorder Columns')) ->setHref($reorder_uri) ->setDisabled(!$can_edit) ->setWorkflow(true); if ($show_hidden) { $hidden_uri = $this->getURIWithState() ->removeQueryParam('hidden'); $hidden_icon = 'fa-eye-slash'; $hidden_text = pht('Hide Hidden Columns'); } else { $hidden_uri = $this->getURIWithState() ->replaceQueryParam('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); $manage_items[] = id(new PhabricatorActionView()) ->setType(PhabricatorActionView::TYPE_DIVIDER); $background_uri = $this->getApplicationURI("board/{$id}/background/"); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-paint-brush') ->setName(pht('Change Background Color')) ->setHref($background_uri) ->setDisabled(!$can_edit) ->setWorkflow(false); $manage_uri = $this->getApplicationURI("board/{$id}/manage/"); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-gear') ->setName(pht('Manage Workboard')) ->setHref($manage_uri); $batch_edit_uri = $request->getRequestURI(); $batch_edit_uri->replaceQueryParam('batch', self::BATCH_EDIT_ALL); $can_batch_edit = PhabricatorPolicyFilter::hasCapability( $viewer, PhabricatorApplication::getByClass('PhabricatorManiphestApplication'), ManiphestBulkEditCapability::CAPABILITY); $manage_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($manage_items as $item) { $manage_menu->addAction($item); } $manage_button = id(new PHUIListItemView()) ->setIcon('fa-cog') ->setHref('#') ->addSigil('boards-dropdown-menu') ->addSigil('has-tooltip') ->setMetadata( array( 'tip' => pht('Manage'), 'align' => 'S', 'items' => hsprintf('%s', $manage_menu), )); return $manage_button; } private function buildFullscreenMenu() { $up = id(new PHUIListItemView()) ->setIcon('fa-arrows-alt') ->setHref('#') ->addClass('phui-workboard-expand-icon') ->addSigil('jx-toggle-class') ->addSigil('has-tooltip') ->setMetaData(array( 'tip' => pht('Fullscreen'), 'map' => array( 'phabricator-standard-page' => 'phui-workboard-fullscreen', ), )); return $up; } 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(); } $specs = id(new ManiphestEditEngine()) ->setViewer($viewer) ->newCreateActionSpecifications(array()); foreach ($specs as $spec) { $column_items[] = id(new PhabricatorActionView()) ->setIcon($spec['icon']) ->setName($spec['name']) ->setHref($spec['uri']) ->setDisabled($spec['disabled']) ->addSigil('column-add-task') ->setMetadata( array( 'createURI' => $spec['uri'], 'columnPHID' => $column->getPHID(), 'boardPHID' => $project->getPHID(), 'projectPHID' => $default_phid, )); } $column_items[] = id(new PhabricatorActionView()) ->setType(PhabricatorActionView::TYPE_DIVIDER); $batch_edit_uri = $request->getRequestURI(); $batch_edit_uri->replaceQueryParam('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('Bulk Edit Tasks...')) ->setHref($batch_edit_uri) ->setDisabled(!$can_batch_edit); $batch_move_uri = $request->getRequestURI(); $batch_move_uri->replaceQueryParam('move', $column->getID()); $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-arrow-right') ->setName(pht('Move Tasks to Column...')) ->setHref($batch_move_uri) ->setWorkflow(true); $query_uri = $request->getRequestURI(); $query_uri->replaceQueryParam('queryColumnID', $column->getID()); $column_items[] = id(new PhabricatorActionView()) ->setName(pht('View as Query')) ->setIcon('fa-search') ->setHref($query_uri); $edit_uri = 'board/'.$this->id.'/edit/'.$column->getID().'/'; $column_items[] = id(new PhabricatorActionView()) ->setName(pht('Edit Column')) ->setIcon('fa-pencil') ->setHref($this->getApplicationURI($edit_uri)) ->setDisabled(!$can_edit) ->setWorkflow(true); $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-pencil') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $column_menu), )); return $column_button; } private function buildTriggerMenu(PhabricatorProjectColumn $column) { $viewer = $this->getViewer(); $trigger = $column->getTrigger(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $column, PhabricatorPolicyCapability::CAN_EDIT); $trigger_items = array(); if (!$trigger) { $set_uri = $this->getApplicationURI( new PhutilURI( 'trigger/edit/', array( 'columnPHID' => $column->getPHID(), ))); $trigger_items[] = id(new PhabricatorActionView()) ->setIcon('fa-cogs') ->setName(pht('New Trigger...')) ->setHref($set_uri) ->setDisabled(!$can_edit); } else { $trigger_items[] = id(new PhabricatorActionView()) ->setIcon('fa-cogs') ->setName(pht('View Trigger')) ->setHref($trigger->getURI()) ->setDisabled(!$can_edit); } $remove_uri = $this->getApplicationURI( new PhutilURI( urisprintf( 'column/remove/%d/', $column->getID()))); $trigger_items[] = id(new PhabricatorActionView()) ->setIcon('fa-times') ->setName(pht('Remove Trigger')) ->setHref($remove_uri) ->setWorkflow(true) ->setDisabled(!$can_edit || !$trigger); $trigger_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($trigger_items as $item) { $trigger_menu->addAction($item); } if ($trigger) { $trigger_icon = 'fa-cogs'; } else { $trigger_icon = 'fa-cogs grey'; } $trigger_button = id(new PHUIIconView()) ->setIcon($trigger_icon) ->setHref('#') ->addSigil('boards-dropdown-menu') ->addSigil('trigger-preview') ->setMetadata( array( 'items' => hsprintf('%s', $trigger_menu), 'columnPHID' => $column->getPHID(), )); return $trigger_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. * @param bool True to explicitly include all state. * @return PhutilURI URI with state parameters. */ private function getURIWithState($base = null, $force = false) { $project = $this->getProject(); if ($base === null) { $base = $this->getRequest()->getPath(); } $base = new PhutilURI($base); if ($force || ($this->sortKey != $this->getDefaultSort($project))) { if ($this->sortKey !== null) { $base->replaceQueryParam('order', $this->sortKey); } else { $base->removeQueryParam('order'); } } else { $base->removeQueryParam('order'); } if ($force || ($this->queryKey != $this->getDefaultFilter($project))) { if ($this->queryKey !== null) { $base->replaceQueryParam('filter', $this->queryKey); } else { $base->removeQueryParam('filter'); } } else { $base->removeQueryParam('filter'); } if ($this->showHidden) { $base->replaceQueryParam('hidden', 'true'); } else { $base->removeQueryParam('hidden'); } return $base; } 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 ->getProfileMenuEngine() ->adjustDefault(PhabricatorProject::ITEM_WORKBOARD); } if ($request->isFormPost()) { if ($type == 'backlog-only') { $column = PhabricatorProjectColumn::initializeNewColumn($viewer) ->setSequence(0) ->setProperty('isDefault', true) ->setProjectPHID($project->getPHID()) ->save(); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType( PhabricatorProjectWorkboardTransaction::TRANSACTIONTYPE) ->setNewValue(1); id(new PhabricatorProjectTransactionEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($project, $xactions); 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); } private function buildEnableContent(PhabricatorProject $project) { $request = $this->getRequest(); $viewer = $this->getViewer(); $id = $project->getID(); $profile_uri = $this->getApplicationURI("profile/{$id}/"); $board_uri = $this->getApplicationURI("board/{$id}/"); if ($request->isFormPost()) { $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType( PhabricatorProjectWorkboardTransaction::TRANSACTIONTYPE) ->setNewValue(1); id(new PhabricatorProjectTransactionEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($project, $xactions); return id(new AphrontRedirectResponse()) ->setURI($board_uri); } return $this->newDialog() ->setTitle(pht('Workboard Disabled')) ->addHiddenInput('initialize', 1) ->appendParagraph( pht( 'This workboard has been disabled, but can be restored to its '. 'former glory.')) ->addCancelButton($profile_uri) ->addSubmitButton(pht('Enable Workboard')); } private function buildDisabledContent(PhabricatorProject $project) { $viewer = $this->getViewer(); $id = $project->getID(); $profile_uri = $this->getApplicationURI("profile/{$id}/"); return $this->newDialog() ->setTitle(pht('Workboard Disabled')) ->appendParagraph( pht( 'This workboard has been disabled, and you do not have permission '. 'to enable it. Only users who can edit this project can restore '. 'the workboard.')) ->addCancelButton($profile_uri); } } diff --git a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php index 24efec5ebb..781461a812 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php @@ -1,111 +1,111 @@ getViewer(); $id = $request->getURIData('id'); $project_id = $request->getURIData('projectID'); $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, )) ->withIDs(array($project_id)) ->needImages(true) ->executeOne(); if (!$project) { return new Aphront404Response(); } $this->setProject($project); $project_id = $project->getID(); $column = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, )) ->executeOne(); if (!$column) { return new Aphront404Response(); } $timeline = $this->buildTransactionTimeline( $column, new PhabricatorProjectColumnTransactionQuery()); $timeline->setShouldTerminate(true); $title = $column->getDisplayName(); $header = $this->buildHeaderView($column); $properties = $this->buildPropertyView($column); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Workboard'), "/project/board/{$project_id}/"); + $crumbs->addTextCrumb(pht('Workboard'), $project->getWorkboardURI()); $crumbs->addTextCrumb(pht('Column: %s', $title)); $crumbs->setBorder(true); $nav = $this->getProfileMenu(); require_celerity_resource('project-view-css'); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->addClass('project-view-home') ->addClass('project-view-people-home') ->setMainColumn(array( $properties, $timeline, )); return $this->newPage() ->setTitle($title) ->setNavigation($nav) ->setCrumbs($crumbs) ->appendChild($view); } private function buildHeaderView(PhabricatorProjectColumn $column) { $viewer = $this->getViewer(); $header = id(new PHUIHeaderView()) ->setHeader(pht('Column: %s', $column->getDisplayName())) ->setUser($viewer); if ($column->isHidden()) { $header->setStatus('fa-ban', 'dark', pht('Hidden')); } return $header; } private function buildPropertyView( PhabricatorProjectColumn $column) { $viewer = $this->getViewer(); $properties = id(new PHUIPropertyListView()) ->setUser($viewer) ->setObject($column); $limit = $column->getPointLimit(); if ($limit === null) { $limit_text = pht('No Limit'); } else { $limit_text = $limit; } $properties->addProperty(pht('Point Limit'), $limit_text); $box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Details')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->appendChild($properties); return $box; } } diff --git a/src/applications/project/controller/PhabricatorProjectColumnEditController.php b/src/applications/project/controller/PhabricatorProjectColumnEditController.php index 567b923407..9ddb2b7d8a 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnEditController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnEditController.php @@ -1,144 +1,143 @@ getViewer(); $id = $request->getURIData('id'); $project_id = $request->getURIData('projectID'); $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->withIDs(array($project_id)) ->needImages(true) ->executeOne(); if (!$project) { return new Aphront404Response(); } $this->setProject($project); $is_new = ($id ? false : true); if (!$is_new) { $column = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$column) { return new Aphront404Response(); } } else { $column = PhabricatorProjectColumn::initializeNewColumn($viewer); } $e_name = null; $e_limit = null; $v_limit = $column->getPointLimit(); $v_name = $column->getName(); $validation_exception = null; - $base_uri = '/board/'.$project_id.'/'; - $view_uri = $this->getApplicationURI($base_uri); + $view_uri = $project->getWorkboardURI(); if ($request->isFormPost()) { $v_name = $request->getStr('name'); $v_limit = $request->getStr('limit'); if ($is_new) { $column->setProjectPHID($project->getPHID()); $column->attachProject($project); $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array($project->getPHID())) ->execute(); $new_sequence = 1; if ($columns) { $values = mpull($columns, 'getSequence'); $new_sequence = max($values) + 1; } $column->setSequence($new_sequence); } $xactions = array(); $type_name = PhabricatorProjectColumnNameTransaction::TRANSACTIONTYPE; $type_limit = PhabricatorProjectColumnLimitTransaction::TRANSACTIONTYPE; if (!$column->getProxy()) { $xactions[] = id(new PhabricatorProjectColumnTransaction()) ->setTransactionType($type_name) ->setNewValue($v_name); } $xactions[] = id(new PhabricatorProjectColumnTransaction()) ->setTransactionType($type_limit) ->setNewValue($v_limit); try { $editor = id(new PhabricatorProjectColumnTransactionEditor()) ->setActor($viewer) ->setContinueOnNoEffect(true) ->setContentSourceFromRequest($request) ->applyTransactions($column, $xactions); return id(new AphrontRedirectResponse())->setURI($view_uri); } catch (PhabricatorApplicationTransactionValidationException $ex) { $e_name = $ex->getShortMessage($type_name); $e_limit = $ex->getShortMessage($type_limit); $validation_exception = $ex; } } $form = id(new AphrontFormView()) ->setUser($request->getUser()); if (!$column->getProxy()) { $form->appendChild( id(new AphrontFormTextControl()) ->setValue($v_name) ->setLabel(pht('Name')) ->setName('name') ->setError($e_name)); } $form->appendChild( id(new AphrontFormTextControl()) ->setValue($v_limit) ->setLabel(pht('Point Limit')) ->setName('limit') ->setError($e_limit) ->setCaption( pht('Maximum number of points of tasks allowed in the column.'))); if ($is_new) { $title = pht('Create Column'); $submit = pht('Create Column'); } else { $title = pht('Edit %s', $column->getDisplayName()); $submit = pht('Save Column'); } return $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FORM) ->setTitle($title) ->appendForm($form) ->setValidationException($validation_exception) ->addCancelButton($view_uri) ->addSubmitButton($submit); } } diff --git a/src/applications/project/controller/PhabricatorProjectColumnHideController.php b/src/applications/project/controller/PhabricatorProjectColumnHideController.php index 61811af5c3..254beab78c 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnHideController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnHideController.php @@ -1,149 +1,149 @@ getViewer(); $id = $request->getURIData('id'); $project_id = $request->getURIData('projectID'); $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->withIDs(array($project_id)) ->executeOne(); if (!$project) { return new Aphront404Response(); } $this->setProject($project); $column = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$column) { return new Aphront404Response(); } $column_phid = $column->getPHID(); - $view_uri = $this->getApplicationURI('/board/'.$project_id.'/'); + $view_uri = $project->getWorkboardURI(); $view_uri = new PhutilURI($view_uri); foreach ($request->getPassthroughRequestData() as $key => $value) { $view_uri->replaceQueryParam($key, $value); } if ($column->isDefaultColumn()) { return $this->newDialog() ->setTitle(pht('Can Not Hide Default Column')) ->appendParagraph( pht('You can not hide the default/backlog column on a board.')) ->addCancelButton($view_uri, pht('Okay')); } $proxy = $column->getProxy(); if ($request->isFormPost()) { if ($proxy) { if ($proxy->isArchived()) { $new_status = PhabricatorProjectStatus::STATUS_ACTIVE; } else { $new_status = PhabricatorProjectStatus::STATUS_ARCHIVED; } $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType( PhabricatorProjectStatusTransaction::TRANSACTIONTYPE) ->setNewValue($new_status); id(new PhabricatorProjectTransactionEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($proxy, $xactions); } else { if ($column->isHidden()) { $new_status = PhabricatorProjectColumn::STATUS_ACTIVE; } else { $new_status = PhabricatorProjectColumn::STATUS_HIDDEN; } $type_status = PhabricatorProjectColumnStatusTransaction::TRANSACTIONTYPE; $xactions = array( id(new PhabricatorProjectColumnTransaction()) ->setTransactionType($type_status) ->setNewValue($new_status), ); $editor = id(new PhabricatorProjectColumnTransactionEditor()) ->setActor($viewer) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setContentSourceFromRequest($request) ->applyTransactions($column, $xactions); } return id(new AphrontRedirectResponse())->setURI($view_uri); } if ($proxy) { if ($column->isHidden()) { $title = pht('Activate and Show Column'); $body = pht( 'This column is hidden because it represents an archived '. 'subproject. Do you want to activate the subproject so the '. 'column is visible again?'); $button = pht('Activate Subproject'); } else { $title = pht('Archive and Hide Column'); $body = pht( 'This column is visible because it represents an active '. 'subproject. Do you want to hide the column by archiving the '. 'subproject?'); $button = pht('Archive Subproject'); } } else { if ($column->isHidden()) { $title = pht('Show Column'); $body = pht('Are you sure you want to show this column?'); $button = pht('Show Column'); } else { $title = pht('Hide Column'); $body = pht( 'Are you sure you want to hide this column? It will no longer '. 'appear on the workboard.'); $button = pht('Hide Column'); } } $dialog = $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FORM) ->setTitle($title) ->appendChild($body) ->setDisableWorkflowOnCancel(true) ->addCancelButton($view_uri) ->addSubmitButton($button); foreach ($request->getPassthroughRequestData() as $key => $value) { $dialog->addHiddenInput($key, $value); } return $dialog; } } diff --git a/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php index 5802449dcb..9bb92e5a3a 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php @@ -1,60 +1,60 @@ getViewer(); $id = $request->getURIData('id'); $column = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$column) { return new Aphront404Response(); } - $done_uri = $column->getBoardURI(); + $done_uri = $column->getWorkboardURI(); if (!$column->getTriggerPHID()) { return $this->newDialog() ->setTitle(pht('No Trigger')) ->appendParagraph( pht('This column does not have a trigger.')) ->addCancelButton($done_uri); } if ($request->isFormPost()) { $column_xactions = array(); $column_xactions[] = $column->getApplicationTransactionTemplate() ->setTransactionType( PhabricatorProjectColumnTriggerTransaction::TRANSACTIONTYPE) ->setNewValue(null); $column_editor = $column->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); $column_editor->applyTransactions($column, $column_xactions); return id(new AphrontRedirectResponse())->setURI($done_uri); } $body = pht('Really remove the trigger from this column?'); return $this->newDialog() ->setTitle(pht('Remove Trigger')) ->appendParagraph($body) ->addSubmitButton(pht('Remove Trigger')) ->addCancelButton($done_uri); } } diff --git a/src/applications/project/controller/PhabricatorProjectController.php b/src/applications/project/controller/PhabricatorProjectController.php index c28ace305c..63494bf442 100644 --- a/src/applications/project/controller/PhabricatorProjectController.php +++ b/src/applications/project/controller/PhabricatorProjectController.php @@ -1,188 +1,210 @@ project = $project; return $this; } protected function getProject() { return $this->project; } protected function loadProject() { $viewer = $this->getViewer(); $request = $this->getRequest(); $id = nonempty( $request->getURIData('projectID'), $request->getURIData('id')); $slug = $request->getURIData('slug'); if ($slug) { $normal_slug = PhabricatorSlug::normalizeProjectSlug($slug); $is_abnormal = ($slug !== $normal_slug); $normal_uri = "/tag/{$normal_slug}/"; } else { $is_abnormal = false; } $query = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->needMembers(true) ->needWatchers(true) ->needImages(true) ->needSlugs(true); if ($slug) { $query->withSlugs(array($slug)); } else { $query->withIDs(array($id)); } $policy_exception = null; try { $project = $query->executeOne(); } catch (PhabricatorPolicyException $ex) { $policy_exception = $ex; $project = null; } if (!$project) { // This project legitimately does not exist, so just 404 the user. if (!$policy_exception) { return new Aphront404Response(); } // Here, the project exists but the user can't see it. If they are // using a non-canonical slug to view the project, redirect to the // canonical slug. If they're already using the canonical slug, rethrow // the exception to give them the policy error. if ($is_abnormal) { return id(new AphrontRedirectResponse())->setURI($normal_uri); } else { throw $policy_exception; } } // The user can view the project, but is using a noncanonical slug. // Redirect to the canonical slug. $primary_slug = $project->getPrimarySlug(); if ($slug && ($slug !== $primary_slug)) { $primary_uri = "/tag/{$primary_slug}/"; return id(new AphrontRedirectResponse())->setURI($primary_uri); } $this->setProject($project); return null; } public function buildApplicationMenu() { $menu = $this->newApplicationMenu(); $profile_menu = $this->getProfileMenu(); if ($profile_menu) { $menu->setProfileMenu($profile_menu); } $menu->setSearchEngine(new PhabricatorProjectSearchEngine()); return $menu; } protected function getProfileMenu() { if (!$this->profileMenu) { $engine = $this->getProfileMenuEngine(); if ($engine) { $this->profileMenu = $engine->buildNavigation(); } } return $this->profileMenu; } protected function buildApplicationCrumbs() { + return $this->newApplicationCrumbs('profile'); + } + + protected function newWorkboardCrumbs() { + return $this->newApplicationCrumbs('workboard'); + } + + private function newApplicationCrumbs($mode) { $crumbs = parent::buildApplicationCrumbs(); $project = $this->getProject(); if ($project) { $ancestors = $project->getAncestorProjects(); $ancestors = array_reverse($ancestors); $ancestors[] = $project; foreach ($ancestors as $ancestor) { - $crumbs->addTextCrumb( - $ancestor->getName(), - $ancestor->getProfileURI() - ); + if ($ancestor->getPHID() === $project->getPHID()) { + // Link the current project's crumb to its profile no matter what, + // since we're already on the right context page for it and linking + // to the current page isn't helpful. + $crumb_uri = $ancestor->getProfileURI(); + } else { + switch ($mode) { + case 'workboard': + $crumb_uri = $ancestor->getWorkboardURI(); + break; + case 'profile': + default: + $crumb_uri = $ancestor->getProfileURI(); + break; + } + } + + $crumbs->addTextCrumb($ancestor->getName(), $crumb_uri); } } return $crumbs; } protected function getProfileMenuEngine() { if (!$this->profileMenuEngine) { $viewer = $this->getViewer(); $project = $this->getProject(); if ($project) { $engine = id(new PhabricatorProjectProfileMenuEngine()) ->setViewer($viewer) ->setController($this) ->setProfileObject($project); $this->profileMenuEngine = $engine; } } return $this->profileMenuEngine; } protected function setProfileMenuEngine( PhabricatorProjectProfileMenuEngine $engine) { $this->profileMenuEngine = $engine; return $this; } protected function newCardResponse( $board_phid, $object_phid, PhabricatorProjectColumnOrder $ordering = null, $sounds = array()) { $viewer = $this->getViewer(); $request = $this->getRequest(); $visible_phids = $request->getStrList('visiblePHIDs'); if (!$visible_phids) { $visible_phids = array(); } $engine = id(new PhabricatorBoardResponseEngine()) ->setViewer($viewer) ->setBoardPHID($board_phid) ->setObjectPHID($object_phid) ->setVisiblePHIDs($visible_phids) ->setSounds($sounds); if ($ordering) { $engine->setOrdering($ordering); } return $engine->buildResponse(); } public function renderHashtags(array $tags) { $result = array(); foreach ($tags as $key => $tag) { $result[] = '#'.$tag; } return implode(', ', $result); } } diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php index 7189df70ec..df362efb61 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php @@ -1,293 +1,293 @@ getRequest(); $viewer = $request->getViewer(); $id = $request->getURIData('id'); if ($id) { $trigger = id(new PhabricatorProjectTriggerQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$trigger) { return new Aphront404Response(); } } else { $trigger = PhabricatorProjectTrigger::initializeNewTrigger(); } $column_phid = $request->getStr('columnPHID'); if ($column_phid) { $column = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withPHIDs(array($column_phid)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$column) { return new Aphront404Response(); } - $board_uri = $column->getBoardURI(); + $board_uri = $column->getWorkboardURI(); } else { $column = null; $board_uri = null; } if ($board_uri) { $cancel_uri = $board_uri; } else if ($trigger->getID()) { $cancel_uri = $trigger->getURI(); } else { $cancel_uri = $this->getApplicationURI('trigger/'); } $v_name = $trigger->getName(); $v_edit = $trigger->getEditPolicy(); $v_rules = $trigger->getTriggerRules(); $e_name = null; $e_edit = null; $validation_exception = null; if ($request->isFormPost()) { try { $v_name = $request->getStr('name'); $v_edit = $request->getStr('editPolicy'); // Read the JSON rules from the request and convert them back into // "TriggerRule" objects so we can render the correct form state // if the user is modifying the rules $raw_rules = $request->getStr('rules'); $raw_rules = phutil_json_decode($raw_rules); $copy = clone $trigger; $copy->setRuleset($raw_rules); $v_rules = $copy->getTriggerRules(); $xactions = array(); if (!$trigger->getID()) { $xactions[] = $trigger->getApplicationTransactionTemplate() ->setTransactionType(PhabricatorTransactions::TYPE_CREATE) ->setNewValue(true); } $xactions[] = $trigger->getApplicationTransactionTemplate() ->setTransactionType( PhabricatorProjectTriggerNameTransaction::TRANSACTIONTYPE) ->setNewValue($v_name); $xactions[] = $trigger->getApplicationTransactionTemplate() ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) ->setNewValue($v_edit); $xactions[] = $trigger->getApplicationTransactionTemplate() ->setTransactionType( PhabricatorProjectTriggerRulesetTransaction::TRANSACTIONTYPE) ->setNewValue($raw_rules); $editor = $trigger->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true); $editor->applyTransactions($trigger, $xactions); $next_uri = $trigger->getURI(); if ($column) { $column_xactions = array(); $column_xactions[] = $column->getApplicationTransactionTemplate() ->setTransactionType( PhabricatorProjectColumnTriggerTransaction::TRANSACTIONTYPE) ->setNewValue($trigger->getPHID()); $column_editor = $column->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); $column_editor->applyTransactions($column, $column_xactions); - $next_uri = $column->getBoardURI(); + $next_uri = $column->getWorkboardURI(); } return id(new AphrontRedirectResponse())->setURI($next_uri); } catch (PhabricatorApplicationTransactionValidationException $ex) { $validation_exception = $ex; $e_name = $ex->getShortMessage( PhabricatorProjectTriggerNameTransaction::TRANSACTIONTYPE); $e_edit = $ex->getShortMessage( PhabricatorTransactions::TYPE_EDIT_POLICY); $trigger->setEditPolicy($v_edit); } } if ($trigger->getID()) { $title = $trigger->getObjectName(); $submit = pht('Save Trigger'); $header = pht('Edit Trigger: %s', $trigger->getObjectName()); } else { $title = pht('New Trigger'); $submit = pht('Create Trigger'); $header = pht('New Trigger'); } $form_id = celerity_generate_unique_node_id(); $table_id = celerity_generate_unique_node_id(); $create_id = celerity_generate_unique_node_id(); $input_id = celerity_generate_unique_node_id(); $form = id(new AphrontFormView()) ->setViewer($viewer) ->setID($form_id); if ($column) { $form->addHiddenInput('columnPHID', $column->getPHID()); } $form->appendControl( id(new AphrontFormTextControl()) ->setLabel(pht('Name')) ->setName('name') ->setValue($v_name) ->setError($e_name) ->setPlaceholder($trigger->getDefaultName())); $policies = id(new PhabricatorPolicyQuery()) ->setViewer($viewer) ->setObject($trigger) ->execute(); $form->appendControl( id(new AphrontFormPolicyControl()) ->setName('editPolicy') ->setPolicyObject($trigger) ->setCapability(PhabricatorPolicyCapability::CAN_EDIT) ->setPolicies($policies) ->setError($e_edit)); $form->appendChild( phutil_tag( 'input', array( 'type' => 'hidden', 'name' => 'rules', 'id' => $input_id, ))); $form->appendChild( id(new PHUIFormInsetView()) ->setTitle(pht('Rules')) ->setDescription( pht( 'When a card is dropped into a column which uses this trigger:')) ->setRightButton( javelin_tag( 'a', array( 'href' => '#', 'class' => 'button button-green', 'id' => $create_id, 'mustcapture' => true, ), pht('New Rule'))) ->setContent( javelin_tag( 'table', array( 'id' => $table_id, 'class' => 'trigger-rules-table', )))); $this->setupEditorBehavior( $trigger, $v_rules, $form_id, $table_id, $create_id, $input_id); $form->appendControl( id(new AphrontFormSubmitControl()) ->setValue($submit) ->addCancelButton($cancel_uri)); $header = id(new PHUIHeaderView()) ->setHeader($header); $box_view = id(new PHUIObjectBoxView()) ->setHeader($header) ->setValidationException($validation_exception) ->appendChild($form); $column_view = id(new PHUITwoColumnView()) ->setFooter($box_view); $crumbs = $this->buildApplicationCrumbs() ->setBorder(true); if ($column) { $crumbs->addTextCrumb( pht( '%s: %s', $column->getProject()->getDisplayName(), $column->getName()), $board_uri); } $crumbs->addTextCrumb($title); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($column_view); } private function setupEditorBehavior( PhabricatorProjectTrigger $trigger, array $rule_list, $form_id, $table_id, $create_id, $input_id) { $rule_list = mpull($rule_list, 'toDictionary'); $rule_list = array_values($rule_list); $type_list = PhabricatorProjectTriggerRule::getAllTriggerRules(); $type_list = mpull($type_list, 'newTemplate'); $type_list = array_values($type_list); require_celerity_resource('project-triggers-css'); Javelin::initBehavior( 'trigger-rule-editor', array( 'formNodeID' => $form_id, 'tableNodeID' => $table_id, 'createNodeID' => $create_id, 'inputNodeID' => $input_id, 'rules' => $rule_list, 'types' => $type_list, )); } } diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php index e18419fedb..d148c0a421 100644 --- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php @@ -1,231 +1,231 @@ getRequest(); $viewer = $request->getViewer(); $id = $request->getURIData('id'); $trigger = id(new PhabricatorProjectTriggerQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->executeOne(); if (!$trigger) { return new Aphront404Response(); } $rules_view = $this->newRulesView($trigger); $columns_view = $this->newColumnsView($trigger); $title = $trigger->getObjectName(); $header = id(new PHUIHeaderView()) ->setHeader($trigger->getDisplayName()); $timeline = $this->buildTransactionTimeline( $trigger, new PhabricatorProjectTriggerTransactionQuery()); $timeline->setShouldTerminate(true); $curtain = $this->newCurtain($trigger); $column_view = id(new PHUITwoColumnView()) ->setHeader($header) ->setCurtain($curtain) ->setMainColumn( array( $rules_view, $columns_view, $timeline, )); $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb($trigger->getObjectName()) ->setBorder(true); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($column_view); } private function newColumnsView(PhabricatorProjectTrigger $trigger) { $viewer = $this->getViewer(); // NOTE: When showing columns which use this trigger, we want to represent // all columns the trigger is used by: even columns the user can't see. // If we hide columns the viewer can't see, they might think that the // trigger isn't widely used and is safe to edit, when it may actually // be in use on workboards they don't have access to. // Query the columns with the omnipotent viewer first, then pull out their // PHIDs and throw the actual objects away. Re-query with the real viewer // so we load only the columns they can actually see, but have a list of // all the impacted column PHIDs. // (We're also exposing the status of columns the user might not be able // to see. This technically violates policy, but the trigger usage table // hints at it anyway and it seems unlikely to ever have any security // impact, but is useful in assessing whether a trigger is really in use // or not.) $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $all_columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($omnipotent_viewer) ->withTriggerPHIDs(array($trigger->getPHID())) ->execute(); $column_map = mpull($all_columns, 'getStatus', 'getPHID'); if ($column_map) { $visible_columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withPHIDs(array_keys($column_map)) ->execute(); $visible_columns = mpull($visible_columns, null, 'getPHID'); } else { $visible_columns = array(); } $rows = array(); foreach ($column_map as $column_phid => $column_status) { $column = idx($visible_columns, $column_phid); if ($column) { $project = $column->getProject(); $project_name = phutil_tag( 'a', array( 'href' => $project->getURI(), ), $project->getDisplayName()); $column_name = phutil_tag( 'a', array( - 'href' => $column->getBoardURI(), + 'href' => $column->getWorkboardURI(), ), $column->getDisplayName()); } else { $project_name = null; $column_name = phutil_tag('em', array(), pht('Restricted Column')); } if ($column_status == PhabricatorProjectColumn::STATUS_ACTIVE) { $status_icon = id(new PHUIIconView()) ->setIcon('fa-columns', 'blue') ->setTooltip(pht('Active Column')); } else { $status_icon = id(new PHUIIconView()) ->setIcon('fa-eye-slash', 'grey') ->setTooltip(pht('Hidden Column')); } $rows[] = array( $status_icon, $project_name, $column_name, ); } $table_view = id(new AphrontTableView($rows)) ->setNoDataString(pht('This trigger is not used by any columns.')) ->setHeaders( array( null, pht('Project'), pht('Column'), )) ->setColumnClasses( array( null, null, 'wide pri', )); $header_view = id(new PHUIHeaderView()) ->setHeader(pht('Used by Columns')); return id(new PHUIObjectBoxView()) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setHeader($header_view) ->setTable($table_view); } private function newRulesView(PhabricatorProjectTrigger $trigger) { $viewer = $this->getViewer(); $rules = $trigger->getTriggerRules(); $rows = array(); foreach ($rules as $rule) { $value = $rule->getRecord()->getValue(); $rows[] = array( $rule->getRuleViewIcon($value), $rule->getRuleViewLabel(), $rule->getRuleViewDescription($value), ); } $table_view = id(new AphrontTableView($rows)) ->setNoDataString(pht('This trigger has no rules.')) ->setHeaders( array( null, pht('Rule'), pht('Action'), )) ->setColumnClasses( array( null, 'pri', 'wide', )); $header_view = id(new PHUIHeaderView()) ->setHeader(pht('Trigger Rules')) ->setSubheader( pht( 'When a card is dropped into a column that uses this trigger, '. 'these actions will be taken.')); return id(new PHUIObjectBoxView()) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setHeader($header_view) ->setTable($table_view); } private function newCurtain(PhabricatorProjectTrigger $trigger) { $viewer = $this->getViewer(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $trigger, PhabricatorPolicyCapability::CAN_EDIT); $curtain = $this->newCurtainView($trigger); $edit_uri = $this->getApplicationURI( urisprintf( 'trigger/edit/%d/', $trigger->getID())); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Trigger')) ->setIcon('fa-pencil') ->setHref($edit_uri) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); return $curtain; } } diff --git a/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php b/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php index c69e130275..7251323415 100644 --- a/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php +++ b/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php @@ -1,91 +1,91 @@ getViewer(); $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); $has_projects = (bool)$project_phids; $project_phids = array_reverse($project_phids); $handles = $viewer->loadHandles($project_phids); // 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 ($has_projects && $can_appear_on_boards) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->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' => "/project/board/{$project_id}/", + 'href' => $column->getWorkboardURI(), 'class' => 'maniphest-board-link', ), $column_name); $annotation[] = $column_link; } if ($annotation) { $annotations[$project_phid] = array( ' ', phutil_implode_html(', ', $annotation), ); } } } if ($has_projects) { $list = id(new PHUIHandleTagListView()) ->setHandles($handles) ->setAnnotations($annotations) ->setShowHovercards(true); } else { $list = phutil_tag('em', array(), pht('None')); } return $this->newPanel() ->setHeaderText(pht('Tags')) ->setOrder(10000) ->appendChild($list); } } diff --git a/src/applications/project/events/PhabricatorProjectUIEventListener.php b/src/applications/project/events/PhabricatorProjectUIEventListener.php index 104084bbf7..25d1ba9f74 100644 --- a/src/applications/project/events/PhabricatorProjectUIEventListener.php +++ b/src/applications/project/events/PhabricatorProjectUIEventListener.php @@ -1,115 +1,115 @@ listen(PhabricatorEventType::TYPE_UI_WILLRENDERPROPERTIES); } public function handleEvent(PhutilEvent $event) { $object = $event->getValue('object'); switch ($event->getType()) { case PhabricatorEventType::TYPE_UI_WILLRENDERPROPERTIES: // Hacky solution so that property list view on Diffusion // commits shows build status, but not Projects, Subscriptions, // or Tokens. if ($object instanceof PhabricatorRepositoryCommit) { return; } $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' => "/project/board/{$project_id}/", + 'href' => $column->getWorkboardURI(), '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/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php index 80ec0d835a..38b9632d93 100644 --- a/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php @@ -1,73 +1,73 @@ getViewer(); // Workboards are only available if Maniphest is installed. $class = 'PhabricatorManiphestApplication'; if (!PhabricatorApplication::isClassInstalledForViewer($class, $viewer)) { return false; } return true; } public function getDisplayName( PhabricatorProfileMenuItemConfiguration $config) { $name = $config->getMenuItemProperty('name'); if (strlen($name)) { return $name; } return $this->getDefaultName(); } public function buildEditEngineFields( PhabricatorProfileMenuItemConfiguration $config) { return array( id(new PhabricatorTextEditField()) ->setKey('name') ->setLabel(pht('Name')) ->setPlaceholder($this->getDefaultName()) ->setValue($config->getMenuItemProperty('name')), ); } protected function newNavigationMenuItems( PhabricatorProfileMenuItemConfiguration $config) { $project = $config->getProfileObject(); $id = $project->getID(); - $href = "/project/board/{$id}/"; + $href = $project->getWorkboardURI(); $name = $this->getDisplayName($config); $item = $this->newItem() ->setHref($href) ->setName($name) ->setIcon('fa-columns'); return array( $item, ); } } diff --git a/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php b/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php index 07c7f7a0ee..c58bb44671 100644 --- a/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php +++ b/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php @@ -1,48 +1,48 @@ withPHIDs($phids); } public function loadHandles( PhabricatorHandleQuery $query, array $handles, array $objects) { foreach ($handles as $phid => $handle) { $column = $objects[$phid]; $handle->setName($column->getDisplayName()); - $handle->setURI('/project/board/'.$column->getProject()->getID().'/'); + $handle->setURI($column->getWorkboardURI()); if ($column->isHidden()) { $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); } } } } diff --git a/src/applications/project/storage/PhabricatorProject.php b/src/applications/project/storage/PhabricatorProject.php index 5182a941bf..67ab05f5fd 100644 --- a/src/applications/project/storage/PhabricatorProject.php +++ b/src/applications/project/storage/PhabricatorProject.php @@ -1,907 +1,911 @@ 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); // If this is the child of some other project, default the Space to the // Space of the parent. if ($parent) { $space_phid = $parent->getSpacePHID(); } else { $space_phid = $actor->getDefaultSpacePHID(); } $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) ->setSpacePHID($space_phid) ->setIsMembershipLocked(0) ->attachMemberPHIDs(array()) ->attachSlugs(array()) ->setHasWorkboard(0) ->setHasMilestones(0) ->setHasSubprojects(0) ->setSubtype(PhabricatorEditEngineSubtype::SUBTYPE_DEFAULT) ->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_SERIALIZATION => array( 'properties' => self::SERIALIZATION_JSON, ), 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', 'subtype' => 'text64', ), 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 isUserAncestorWatcher($user_phid) { $is_watcher = $this->isUserWatcher($user_phid); if (!$is_watcher) { $parent = $this->getParentProject(); if ($parent) { return $parent->isUserWatcher($user_phid); } } return $is_watcher; } public function getWatchedAncestorPHID($user_phid) { if ($this->isUserWatcher($user_phid)) { return $this->getPHID(); } $parent = $this->getParentProject(); if ($parent) { return $parent->getWatchedAncestorPHID($user_phid); } return null; } 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 getAllAncestorWatcherPHIDs() { $parent = $this->getParentProject(); if ($parent) { $watchers = $parent->getAllAncestorWatcherPHIDs(); } else { $watchers = array(); } foreach ($this->getWatcherPHIDs() as $phid) { $watchers[$phid] = $phid; } return $watchers; } 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 getProfileURI() { $id = $this->getID(); return "/project/profile/{$id}/"; } + public function getWorkboardURI() { + return urisprintf('/project/board/%d/', $this->getID()); + } + public function save() { if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); } 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->getDisplayName(); $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 %LQ', $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 $this->getParentProject()->getColor(); } 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); } public function getProperty($key, $default = null) { return idx($this->properties, $key, $default); } public function setProperty($key, $value) { $this->properties[$key] = $value; return $this; } public function getDefaultWorkboardSort() { return $this->getProperty('workboard.sort.default'); } public function setDefaultWorkboardSort($sort) { return $this->setProperty('workboard.sort.default', $sort); } public function getDefaultWorkboardFilter() { return $this->getProperty('workboard.filter.default'); } public function setDefaultWorkboardFilter($filter) { return $this->setProperty('workboard.filter.default', $filter); } public function getWorkboardBackgroundColor() { return $this->getProperty('workboard.background'); } public function setWorkboardBackgroundColor($color) { return $this->setProperty('workboard.background', $color); } public function getDisplayWorkboardBackgroundColor() { $color = $this->getWorkboardBackgroundColor(); if ($color === null) { $parent = $this->getParentProject(); if ($parent) { return $parent->getDisplayWorkboardBackgroundColor(); } } if ($color === 'none') { $color = null; } return $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 getApplicationTransactionTemplate() { return new PhabricatorProjectTransaction(); } /* -( PhabricatorSpacesInterface )----------------------------------------- */ public function getSpacePHID() { if ($this->isMilestone()) { return $this->getParentProject()->getSpacePHID(); } return $this->spacePHID; } /* -( 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(); } /* -( PhabricatorFerretInterface )--------------------------------------- */ public function newFerretEngine() { return new PhabricatorProjectFerretEngine(); } /* -( 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('subtype') ->setType('string') ->setDescription(pht('Subtype of the project.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('milestone') ->setType('int?') ->setDescription(pht('For milestones, milestone sequence number.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('parent') ->setType('map?') ->setDescription( pht( 'For subprojects and milestones, a brief description of the '. 'parent project.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('depth') ->setType('int') ->setDescription( pht( 'For subprojects and milestones, depth of this project in the '. 'tree. Root projects have depth 0.')), 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); if ($this->isMilestone()) { $milestone = (int)$this->getMilestoneNumber(); } else { $milestone = null; } $parent = $this->getParentProject(); if ($parent) { $parent_ref = $parent->getRefForConduit(); } else { $parent_ref = null; } return array( 'name' => $this->getName(), 'slug' => $this->getPrimarySlug(), 'subtype' => $this->getSubtype(), 'milestone' => $milestone, 'depth' => (int)$this->getProjectDepth(), 'parent' => $parent_ref, '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'), id(new PhabricatorProjectsAncestorsSearchEngineAttachment()) ->setAttachmentKey('ancestors'), ); } /** * Get an abbreviated representation of this project for use in providing * "parent" and "ancestor" information. */ public function getRefForConduit() { return array( 'id' => (int)$this->getID(), 'phid' => $this->getPHID(), 'name' => $this->getName(), ); } /* -( 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; } /* -( PhabricatorEditEngineSubtypeInterface )------------------------------ */ public function getEditEngineSubtype() { return $this->getSubtype(); } public function setEditEngineSubtype($value) { return $this->setSubtype($value); } public function newEditEngineSubtypeMap() { $config = PhabricatorEnv::getEnvConfig('projects.subtypes'); return PhabricatorEditEngineSubtype::newSubtypeMap($config); } public function newSubtypeObject() { $subtype_key = $this->getEditEngineSubtype(); $subtype_map = $this->newEditEngineSubtypeMap(); return $subtype_map->getSubtype($subtype_key); } } diff --git a/src/applications/project/storage/PhabricatorProjectColumn.php b/src/applications/project/storage/PhabricatorProjectColumn.php index 731c2a15fb..49d7f28a9f 100644 --- a/src/applications/project/storage/PhabricatorProjectColumn.php +++ b/src/applications/project/storage/PhabricatorProjectColumn.php @@ -1,364 +1,362 @@ setName('') ->setStatus(self::STATUS_ACTIVE) ->attachProxy(null); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text255', 'status' => 'uint32', 'sequence' => 'uint32', 'proxyPHID' => 'phid?', 'triggerPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_status' => array( 'columns' => array('projectPHID', 'status', 'sequence'), ), 'key_sequence' => array( 'columns' => array('projectPHID', 'sequence'), ), 'key_proxy' => array( 'columns' => array('projectPHID', 'proxyPHID'), 'unique' => true, ), 'key_trigger' => array( 'columns' => array('triggerPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorProjectColumnPHIDType::TYPECONST); } public function attachProject(PhabricatorProject $project) { $this->project = $project; return $this; } public function getProject() { 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'); } public function isHidden() { $proxy = $this->getProxy(); if ($proxy) { return $proxy->isArchived(); } return ($this->getStatus() == self::STATUS_HIDDEN); } public function getDisplayName() { $proxy = $this->getProxy(); if ($proxy) { return $proxy->getProxyColumnName(); } $name = $this->getName(); if (strlen($name)) { return $name; } if ($this->isDefaultColumn()) { return pht('Backlog'); } return pht('Unnamed Column'); } public function getDisplayType() { if ($this->isDefaultColumn()) { return pht('(Default)'); } if ($this->isHidden()) { return pht('(Hidden)'); } return null; } public function getDisplayClass() { $proxy = $this->getProxy(); if ($proxy) { return $proxy->getProxyColumnClass(); } return null; } public function getHeaderIcon() { $proxy = $this->getProxy(); if ($proxy) { return $proxy->getProxyColumnIcon(); } if ($this->isHidden()) { return 'fa-eye-slash'; } return null; } public function getProperty($key, $default = null) { return idx($this->properties, $key, $default); } public function setProperty($key, $value) { $this->properties[$key] = $value; return $this; } public function getPointLimit() { return $this->getProperty('pointLimit'); } public function setPointLimit($limit) { $this->setProperty('pointLimit', $limit); return $this; } public function getOrderingKey() { $proxy = $this->getProxy(); // Normal columns and subproject columns go first, in a user-controlled // order. // All the milestone columns go last, in their sequential order. if (!$proxy || !$proxy->isMilestone()) { $group = 'A'; $sequence = $this->getSequence(); } else { $group = 'B'; $sequence = $proxy->getMilestoneNumber(); } return sprintf('%s%012d', $group, $sequence); } public function attachTrigger(PhabricatorProjectTrigger $trigger = null) { $this->trigger = $trigger; return $this; } public function getTrigger() { return $this->assertAttached($this->trigger); } public function canHaveTrigger() { // Backlog columns and proxy (subproject / milestone) columns can't have // triggers because cards routinely end up in these columns through tag // edits rather than drag-and-drop and it would likely be confusing to // have these triggers act only a small fraction of the time. if ($this->isDefaultColumn()) { return false; } if ($this->getProxy()) { return false; } return true; } - public function getBoardURI() { - return urisprintf( - '/project/board/%d/', - $this->getProject()->getID()); + public function getWorkboardURI() { + return $this->getProject()->getWorkboardURI(); } public function getDropEffects() { $effects = array(); $proxy = $this->getProxy(); if ($proxy && $proxy->isMilestone()) { $effects[] = id(new PhabricatorProjectDropEffect()) ->setIcon($proxy->getProxyColumnIcon()) ->setColor('violet') ->setContent( pht( 'Move to milestone %s.', phutil_tag('strong', array(), $this->getDisplayName()))); } else { $effects[] = id(new PhabricatorProjectDropEffect()) ->setIcon('fa-columns') ->setColor('blue') ->setContent( pht( 'Move to column %s.', phutil_tag('strong', array(), $this->getDisplayName()))); } if ($this->canHaveTrigger()) { $trigger = $this->getTrigger(); if ($trigger) { foreach ($trigger->getDropEffects() as $trigger_effect) { $effects[] = $trigger_effect; } } } return $effects; } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('name') ->setType('string') ->setDescription(pht('The display name of the column.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('project') ->setType('map') ->setDescription(pht('The project the column belongs to.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('proxyPHID') ->setType('phid?') ->setDescription( pht( 'For columns that proxy another object (like a subproject or '. 'milestone), the PHID of the object they proxy.')), ); } public function getFieldValuesForConduit() { return array( 'name' => $this->getDisplayName(), 'proxyPHID' => $this->getProxyPHID(), 'project' => $this->getProject()->getRefForConduit(), ); } public function getConduitSearchAttachments() { return array(); } public function getRefForConduit() { return array( 'id' => (int)$this->getID(), 'phid' => $this->getPHID(), 'name' => $this->getDisplayName(), ); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorProjectColumnTransactionEditor(); } public function getApplicationTransactionTemplate() { return new PhabricatorProjectColumnTransaction(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { // NOTE: Column policies are enforced as an extended policy which makes // them the same as the project's policies. switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_USER; } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getProject()->hasAutomaticCapability( $capability, $viewer); } public function describeAutomaticCapability($capability) { return pht('Users must be able to see a project to see its board.'); } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { return array( array($this->getProject(), $capability), ); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $this->saveTransaction(); } }