diff --git a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php index 867bf7e9fe..20eb2c42bf 100644 --- a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php +++ b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php @@ -1,858 +1,864 @@ isMilestone = $is_milestone; return $this; } private function getIsMilestone() { return $this->isMilestone; } public function getEditorApplicationClass() { return 'PhabricatorProjectApplication'; } public function getEditorObjectsDescription() { return pht('Projects'); } public function getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_EDGE; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; $types[] = PhabricatorTransactions::TYPE_JOIN_POLICY; $types[] = PhabricatorProjectTransaction::TYPE_NAME; $types[] = PhabricatorProjectTransaction::TYPE_SLUGS; $types[] = PhabricatorProjectTransaction::TYPE_STATUS; $types[] = PhabricatorProjectTransaction::TYPE_IMAGE; $types[] = PhabricatorProjectTransaction::TYPE_ICON; $types[] = PhabricatorProjectTransaction::TYPE_COLOR; $types[] = PhabricatorProjectTransaction::TYPE_LOCKED; $types[] = PhabricatorProjectTransaction::TYPE_PARENT; $types[] = PhabricatorProjectTransaction::TYPE_MILESTONE; return $types; } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: return $object->getName(); case PhabricatorProjectTransaction::TYPE_SLUGS: $slugs = $object->getSlugs(); $slugs = mpull($slugs, 'getSlug', 'getSlug'); unset($slugs[$object->getPrimarySlug()]); return array_keys($slugs); case PhabricatorProjectTransaction::TYPE_STATUS: return $object->getStatus(); case PhabricatorProjectTransaction::TYPE_IMAGE: return $object->getProfileImagePHID(); case PhabricatorProjectTransaction::TYPE_ICON: return $object->getIcon(); case PhabricatorProjectTransaction::TYPE_COLOR: return $object->getColor(); case PhabricatorProjectTransaction::TYPE_LOCKED: return (int)$object->getIsMembershipLocked(); case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: return null; } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: case PhabricatorProjectTransaction::TYPE_ICON: case PhabricatorProjectTransaction::TYPE_COLOR: case PhabricatorProjectTransaction::TYPE_LOCKED: case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: return $xaction->getNewValue(); case PhabricatorProjectTransaction::TYPE_SLUGS: return $this->normalizeSlugs($xaction->getNewValue()); } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: $name = $xaction->getNewValue(); $object->setName($name); if (!$this->getIsMilestone()) { $object->setPrimarySlug(PhabricatorSlug::normalizeProjectSlug($name)); } return; case PhabricatorProjectTransaction::TYPE_SLUGS: return; case PhabricatorProjectTransaction::TYPE_STATUS: $object->setStatus($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_IMAGE: $object->setProfileImagePHID($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_ICON: $object->setIcon($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_COLOR: $object->setColor($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_LOCKED: $object->setIsMembershipLocked($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_PARENT: $object->setParentProjectPHID($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_MILESTONE: $number = $object->getParentProject()->loadNextMilestoneNumber(); $object->setMilestoneNumber($number); $object->setParentProjectPHID($xaction->getNewValue()); return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: // First, add the old name as a secondary slug; this is helpful // for renames and generally a good thing to do. if ($old !== null) { $this->addSlug($object, $old, false); } $this->addSlug($object, $new, false); return; case PhabricatorProjectTransaction::TYPE_SLUGS: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_diff($new, $old); $rem = array_diff($old, $new); foreach ($add as $slug) { $this->addSlug($object, $slug, true); } $this->removeSlugs($object, $rem); return; case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: case PhabricatorProjectTransaction::TYPE_ICON: case PhabricatorProjectTransaction::TYPE_COLOR: case PhabricatorProjectTransaction::TYPE_LOCKED: case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function validateAllTransactions( PhabricatorLiskDAO $object, array $xactions) { $errors = array(); // Prevent creating projects which are both subprojects and milestones, // since this does not make sense, won't work, and will break everything. $parent_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: if ($xaction->getNewValue() === null) { continue; } if (!$parent_xaction) { $parent_xaction = $xaction; continue; } $errors[] = new PhabricatorApplicationTransactionValidationError( $xaction->getTransactionType(), pht('Invalid'), pht( 'When creating a project, specify a maximum of one parent '. 'project or milestone project. A project can not be both a '. 'subproject and a milestone.'), $xaction); break; break; } } $is_milestone = $this->getIsMilestone(); $is_parent = $object->getHasSubprojects(); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_EDGE: $type = $xaction->getMetadataValue('edge:type'); if ($type != PhabricatorProjectProjectHasMemberEdgeType::EDGECONST) { break; } if ($is_parent) { $errors[] = new PhabricatorApplicationTransactionValidationError( $xaction->getTransactionType(), pht('Invalid'), pht( 'You can not change members of a project with subprojects '. 'directly. Members of any subproject are automatically '. 'members of the parent project.'), $xaction); } if ($is_milestone) { $errors[] = new PhabricatorApplicationTransactionValidationError( $xaction->getTransactionType(), pht('Invalid'), pht( 'You can not change members of a milestone. Members of the '. 'parent project are automatically members of the milestone.'), $xaction); } break; } } return $errors; } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); switch ($type) { case PhabricatorProjectTransaction::TYPE_NAME: $missing = $this->validateIsEmptyTextField( $object->getName(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('Project name is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } if (!$xactions) { break; } if ($this->getIsMilestone()) { break; } $name = last($xactions)->getNewValue(); if (!PhabricatorSlug::isValidProjectSlug($name)) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Project names must contain at least one letter or number.'), last($xactions)); break; } $slug = PhabricatorSlug::normalizeProjectSlug($name); $slug_used_already = id(new PhabricatorProjectSlug()) ->loadOneWhere('slug = %s', $slug); if ($slug_used_already && $slug_used_already->getProjectPHID() != $object->getPHID()) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Duplicate'), pht( 'Project name generates the same hashtag ("%s") as another '. 'existing project. Choose a unique name.', '#'.$slug), nonempty(last($xactions), null)); $errors[] = $error; } break; case PhabricatorProjectTransaction::TYPE_SLUGS: if (!$xactions) { break; } $slug_xaction = last($xactions); $new = $slug_xaction->getNewValue(); $invalid = array(); foreach ($new as $slug) { if (!PhabricatorSlug::isValidProjectSlug($slug)) { $invalid[] = $slug; } } if ($invalid) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Hashtags must contain at least one letter or number. %s '. 'project hashtag(s) are invalid: %s.', phutil_count($invalid), implode(', ', $invalid)), $slug_xaction); break; } $new = $this->normalizeSlugs($new); if ($new) { $slugs_used_already = id(new PhabricatorProjectSlug()) ->loadAllWhere('slug IN (%Ls)', $new); } else { // The project doesn't have any extra slugs. $slugs_used_already = array(); } $slugs_used_already = mgroup($slugs_used_already, 'getProjectPHID'); foreach ($slugs_used_already as $project_phid => $used_slugs) { if ($project_phid == $object->getPHID()) { continue; } $used_slug_strs = mpull($used_slugs, 'getSlug'); $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( '%s project hashtag(s) are already used by other projects: %s.', phutil_count($used_slug_strs), implode(', ', $used_slug_strs)), $slug_xaction); $errors[] = $error; } break; case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: if (!$xactions) { break; } $xaction = last($xactions); $parent_phid = $xaction->getNewValue(); if (!$parent_phid) { continue; } if (!$this->getIsNewObject()) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can only set a parent or milestone project when creating a '. 'project for the first time.'), $xaction); break; } $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs(array($parent_phid)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->execute(); if (!$projects) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Parent or milestone project PHID ("%s") must be the PHID of a '. 'valid, visible project which you have permission to edit.', $parent_phid), $xaction); break; } $project = head($projects); if ($project->isMilestone()) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Parent or milestone project PHID ("%s") must not be a '. 'milestone. Milestones may not have subprojects or milestones.', $parent_phid), $xaction); break; } $limit = PhabricatorProject::getProjectDepthLimit(); if ($project->getProjectDepth() >= ($limit - 1)) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can not create a subproject or mielstone under this parent '. 'because it would nest projects too deeply. The maximum '. 'nesting depth of projects is %s.', new PhutilNumber($limit)), $xaction); break; } $object->attachParentProject($project); break; } return $errors; } protected function requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: case PhabricatorProjectTransaction::TYPE_ICON: case PhabricatorProjectTransaction::TYPE_COLOR: PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_EDIT); return; case PhabricatorProjectTransaction::TYPE_LOCKED: PhabricatorPolicyFilter::requireCapability( $this->requireActor(), newv($this->getEditorApplicationClass(), array()), ProjectCanLockProjectsCapability::CAPABILITY); return; case PhabricatorTransactions::TYPE_EDGE: switch ($xaction->getMetadataValue('edge:type')) { case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_keys(array_diff_key($new, $old)); $rem = array_keys(array_diff_key($old, $new)); $actor_phid = $this->requireActor()->getPHID(); $is_join = (($add === array($actor_phid)) && !$rem); $is_leave = (($rem === array($actor_phid)) && !$add); if ($is_join) { // You need CAN_JOIN to join a project. PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_JOIN); } else if ($is_leave) { // You usually don't need any capabilities to leave a project. if ($object->getIsMembershipLocked()) { // you must be able to edit though to leave locked projects PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_EDIT); } } else { // You need CAN_EDIT to change members other than yourself. PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_EDIT); } return; } break; } return parent::requireCapabilities($object, $xaction); } protected function willPublish(PhabricatorLiskDAO $object, array $xactions) { // NOTE: We're using the omnipotent user here because the original actor // may no longer have permission to view the object. return id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($object->getPHID())) ->needAncestorMembers(true) ->executeOne(); } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailSubjectPrefix() { return pht('[Project]'); } protected function getMailTo(PhabricatorLiskDAO $object) { return array( $this->getActingAsPHID(), ); } protected function getMailCc(PhabricatorLiskDAO $object) { return array(); } public function getMailTagsMap() { return array( PhabricatorProjectTransaction::MAILTAG_METADATA => pht('Project name, hashtags, icon, image, or color changes.'), PhabricatorProjectTransaction::MAILTAG_MEMBERS => pht('Project membership changes.'), PhabricatorProjectTransaction::MAILTAG_WATCHERS => pht('Project watcher list changes.'), PhabricatorProjectTransaction::MAILTAG_OTHER => pht('Other project activity not listed above occurs.'), ); } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new ProjectReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $name = $object->getName(); return id(new PhabricatorMetaMTAMail()) ->setSubject("{$name}") ->addHeader('Thread-Topic', "Project {$id}"); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); $uri = '/project/profile/'.$object->getID().'/'; $body->addLinkSection( pht('PROJECT DETAIL'), PhabricatorEnv::getProductionURI($uri)); return $body; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function supportsSearch() { return true; } protected function extractFilePHIDsFromCustomTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_IMAGE: $new = $xaction->getNewValue(); if ($new) { return array($new); } break; } return parent::extractFilePHIDsFromCustomTransaction($object, $xaction); } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { $materialize = false; $new_parent = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_EDGE: switch ($xaction->getMetadataValue('edge:type')) { case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST: $materialize = true; break; } break; case PhabricatorProjectTransaction::TYPE_PARENT: + case PhabricatorProjectTransaction::TYPE_MILESTONE: $materialize = true; $new_parent = $object->getParentProject(); break; } } if ($new_parent) { // If we just created the first subproject of this parent, we want to // copy all of the real members to the subproject. if (!$new_parent->getHasSubprojects()) { $member_type = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; $project_members = PhabricatorEdgeQuery::loadDestinationPHIDs( $new_parent->getPHID(), $member_type); if ($project_members) { $editor = id(new PhabricatorEdgeEditor()); foreach ($project_members as $phid) { $editor->addEdge($object->getPHID(), $member_type, $phid); } $editor->save(); } } } if ($this->getIsNewObject()) { $this->setDefaultProfilePicture($object); } // TODO: We should dump an informational transaction onto the parent // project to show that we created the sub-thing. if ($materialize) { id(new PhabricatorProjectsMembershipIndexEngineExtension()) ->rematerialize($object); } + if ($new_parent) { + id(new PhabricatorProjectsMembershipIndexEngineExtension()) + ->rematerialize($new_parent); + } + return parent::applyFinalEffects($object, $xactions); } private function addSlug(PhabricatorProject $project, $slug, $force) { $slug = PhabricatorSlug::normalizeProjectSlug($slug); $table = new PhabricatorProjectSlug(); $project_phid = $project->getPHID(); if ($force) { // If we have the `$force` flag set, we only want to ignore an existing // slug if it's for the same project. We'll error on collisions with // other projects. $current = $table->loadOneWhere( 'slug = %s AND projectPHID = %s', $slug, $project_phid); } else { // Without the `$force` flag, we'll just return without doing anything // if any other project already has the slug. $current = $table->loadOneWhere( 'slug = %s', $slug); } if ($current) { return; } return id(new PhabricatorProjectSlug()) ->setSlug($slug) ->setProjectPHID($project_phid) ->save(); } private function removeSlugs(PhabricatorProject $project, array $slugs) { if (!$slugs) { return; } // We're going to try to delete both the literal and normalized versions // of all slugs. This allows us to destroy old slugs that are no longer // valid. foreach ($this->normalizeSlugs($slugs) as $slug) { $slugs[] = $slug; } $objects = id(new PhabricatorProjectSlug())->loadAllWhere( 'projectPHID = %s AND slug IN (%Ls)', $project->getPHID(), $slugs); foreach ($objects as $object) { $object->delete(); } } private function normalizeSlugs(array $slugs) { foreach ($slugs as $key => $slug) { $slugs[$key] = PhabricatorSlug::normalizeProjectSlug($slug); } $slugs = array_unique($slugs); $slugs = array_values($slugs); return $slugs; } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = parent::adjustObjectForPolicyChecks($object, $xactions); $type_edge = PhabricatorTransactions::TYPE_EDGE; $edgetype_member = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; $member_xaction = null; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() !== $type_edge) { continue; } $edgetype = $xaction->getMetadataValue('edge:type'); if ($edgetype !== $edgetype_member) { continue; } $member_xaction = $xaction; } if ($member_xaction) { $object_phid = $object->getPHID(); if ($object_phid) { $project = id(new PhabricatorProjectQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($object_phid)) ->needMembers(true) ->executeOne(); $members = $project->getMemberPHIDs(); } else { $members = array(); } $clone_xaction = clone $member_xaction; $hint = $this->getPHIDTransactionNewValue($clone_xaction, $members); $rule = new PhabricatorProjectMembersPolicyRule(); $hint = array_fuse($hint); PhabricatorPolicyRule::passTransactionHintToRule( $copy, $rule, $hint); } return $copy; } protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $actor = $this->getActor(); $actor_phid = $actor->getPHID(); $results = parent::expandTransactions($object, $xactions); $is_milestone = $object->isMilestone(); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_MILESTONE: if ($xaction->getNewValue() !== null) { $is_milestone = true; } break; } } $this->setIsMilestone($is_milestone); return $results; } private function setDefaultProfilePicture(PhabricatorProject $project) { if ($project->isMilestone()) { return; } $compose_color = $project->getDisplayIconComposeColor(); $compose_icon = $project->getDisplayIconComposeIcon(); $builtin = id(new PhabricatorFilesComposeIconBuiltinFile()) ->setColor($compose_color) ->setIcon($compose_icon); $data = $builtin->loadBuiltinFileData(); $file = PhabricatorFile::newFromFileData( $data, array( 'name' => $builtin->getBuiltinDisplayName(), 'profile' => true, 'canCDN' => true, )); $project ->setProfileImagePHID($file->getPHID()) ->save(); } protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { return id(new PhabricatorProjectHeraldAdapter()) ->setProject($object); } } diff --git a/src/applications/project/engine/PhabricatorBoardLayoutEngine.php b/src/applications/project/engine/PhabricatorBoardLayoutEngine.php index 4415e2fdf9..cfd752abb3 100644 --- a/src/applications/project/engine/PhabricatorBoardLayoutEngine.php +++ b/src/applications/project/engine/PhabricatorBoardLayoutEngine.php @@ -1,582 +1,582 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setBoardPHIDs(array $board_phids) { $this->boardPHIDs = array_fuse($board_phids); return $this; } public function getBoardPHIDs() { return $this->boardPHIDs; } public function setObjectPHIDs(array $object_phids) { $this->objectPHIDs = array_fuse($object_phids); return $this; } public function getObjectPHIDs() { return $this->objectPHIDs; } public function executeLayout() { $viewer = $this->getViewer(); $boards = $this->loadBoards(); if (!$boards) { return $this; } $columns = $this->loadColumns($boards); $positions = $this->loadPositions($boards); foreach ($boards as $board_phid => $board) { $board_columns = idx($columns, $board_phid); // Don't layout boards with no columns. These boards need to be formally // created first. if (!$columns) { continue; } $board_positions = idx($positions, $board_phid, array()); $this->layoutBoard($board, $board_columns, $board_positions); } return $this; } public function getColumns($board_phid) { $columns = idx($this->boardLayout, $board_phid, array()); return array_select_keys($this->columnMap, array_keys($columns)); } public function getColumnObjectPHIDs($board_phid, $column_phid) { $columns = idx($this->boardLayout, $board_phid, array()); $positions = idx($columns, $column_phid, array()); return mpull($positions, 'getObjectPHID'); } public function getObjectColumns($board_phid, $object_phid) { $board_map = idx($this->objectColumnMap, $board_phid, array()); $column_phids = idx($board_map, $object_phid); if (!$column_phids) { return array(); } return array_select_keys($this->columnMap, $column_phids); } public function queueRemovePosition( $board_phid, $column_phid, $object_phid) { $board_layout = idx($this->boardLayout, $board_phid, array()); $positions = idx($board_layout, $column_phid, array()); $position = idx($positions, $object_phid); if ($position) { $this->remQueue[] = $position; // If this position hasn't been saved yet, get it out of the add queue. if (!$position->getID()) { foreach ($this->addQueue as $key => $add_position) { if ($add_position === $position) { unset($this->addQueue[$key]); } } } } unset($this->boardLayout[$board_phid][$column_phid][$object_phid]); return $this; } public function queueAddPositionBefore( $board_phid, $column_phid, $object_phid, $before_phid) { return $this->queueAddPositionRelative( $board_phid, $column_phid, $object_phid, $before_phid, true); } public function queueAddPositionAfter( $board_phid, $column_phid, $object_phid, $after_phid) { return $this->queueAddPositionRelative( $board_phid, $column_phid, $object_phid, $after_phid, false); } public function queueAddPosition( $board_phid, $column_phid, $object_phid) { return $this->queueAddPositionRelative( $board_phid, $column_phid, $object_phid, null, true); } private function queueAddPositionRelative( $board_phid, $column_phid, $object_phid, $relative_phid, $is_before) { $board_layout = idx($this->boardLayout, $board_phid, array()); $positions = idx($board_layout, $column_phid, array()); // Check if the object is already in the column, and remove it if it is. $object_position = idx($positions, $object_phid); unset($positions[$object_phid]); if (!$object_position) { $object_position = id(new PhabricatorProjectColumnPosition()) ->setBoardPHID($board_phid) ->setColumnPHID($column_phid) ->setObjectPHID($object_phid); } $found = false; if (!$positions) { $object_position->setSequence(0); } else { foreach ($positions as $position) { if (!$found) { if ($relative_phid === null) { $is_match = true; } else { $position_phid = $position->getObjectPHID(); $is_match = ($relative_phid == $position_phid); } if ($is_match) { $found = true; $sequence = $position->getSequence(); if (!$is_before) { $sequence++; } $object_position->setSequence($sequence++); if (!$is_before) { // If we're inserting after this position, continue the loop so // we don't update it. continue; } } } if ($found) { $position->setSequence($sequence++); $this->addQueue[] = $position; } } } if ($relative_phid && !$found) { throw new Exception( pht( 'Unable to find object "%s" in column "%s" on board "%s".', $relative_phid, $column_phid, $board_phid)); } $this->addQueue[] = $object_position; $positions[$object_phid] = $object_position; $positions = msort($positions, 'getOrderingKey'); $this->boardLayout[$board_phid][$column_phid] = $positions; return $this; } public function applyPositionUpdates() { foreach ($this->remQueue as $position) { if ($position->getID()) { $position->delete(); } } $this->remQueue = array(); $adds = array(); $updates = array(); foreach ($this->addQueue as $position) { $id = $position->getID(); if ($id) { $updates[$id] = $position; } else { $adds[] = $position; } } $this->addQueue = array(); $table = new PhabricatorProjectColumnPosition(); $conn_w = $table->establishConnection('w'); $pairs = array(); foreach ($updates as $id => $position) { // This is ugly because MySQL gets upset with us if it is configured // strictly and we attempt inserts which can't work. We'll never actually // do these inserts since they'll always collide (triggering the ON // DUPLICATE KEY logic), so we just provide dummy values in order to get // there. $pairs[] = qsprintf( $conn_w, '(%d, %d, "", "", "")', $id, $position->getSequence()); } if ($pairs) { queryfx( $conn_w, 'INSERT INTO %T (id, sequence, boardPHID, columnPHID, objectPHID) VALUES %Q ON DUPLICATE KEY UPDATE sequence = VALUES(sequence)', $table->getTableName(), implode(', ', $pairs)); } foreach ($adds as $position) { $position->save(); } return $this; } private function loadBoards() { $viewer = $this->getViewer(); $board_phids = $this->getBoardPHIDs(); $boards = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withPHIDs($board_phids) ->execute(); $boards = mpull($boards, null, 'getPHID'); foreach ($boards as $key => $board) { if (!$board->getHasWorkboard()) { unset($boards[$key]); } } return $boards; } private function loadColumns(array $boards) { $viewer = $this->getViewer(); $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array_keys($boards)) ->execute(); $columns = msort($columns, 'getOrderingKey'); $columns = mpull($columns, null, 'getPHID'); $need_children = array(); foreach ($boards as $phid => $board) { if ($board->getHasMilestones() || $board->getHasSubprojects()) { $need_children[] = $phid; } } if ($need_children) { $children = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withParentProjectPHIDs($need_children) ->execute(); $children = mpull($children, null, 'getPHID'); $children = mgroup($children, 'getParentProjectPHID'); } else { $children = array(); } $columns = mgroup($columns, 'getProjectPHID'); foreach ($boards as $board_phid => $board) { $board_columns = idx($columns, $board_phid, array()); // If the project has milestones, create any missing columns. if ($board->getHasMilestones() || $board->getHasSubprojects()) { $child_projects = idx($children, $board_phid, array()); $next_sequence = last($board_columns)->getSequence() + 1; $proxy_columns = mpull($board_columns, null, 'getProxyPHID'); foreach ($child_projects as $child_phid => $child) { if (isset($proxy_columns[$child_phid])) { continue; } $new_column = PhabricatorProjectColumn::initializeNewColumn($viewer) ->attachProject($board) ->attachProxy($child) ->setSequence($next_sequence++) ->setProjectPHID($board_phid) ->setProxyPHID($child_phid); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $new_column->save(); unset($unguarded); $board_columns[$new_column->getPHID()] = $new_column; } } $board_columns = msort($board_columns, 'getOrderingKey'); $columns[$board_phid] = $board_columns; } foreach ($columns as $board_phid => $board_columns) { foreach ($board_columns as $board_column) { $column_phid = $board_column->getPHID(); $this->columnMap[$column_phid] = $board_column; } } return $columns; } private function loadPositions(array $boards) { $viewer = $this->getViewer(); $object_phids = $this->getObjectPHIDs(); if (!$object_phids) { return array(); } $positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($viewer) ->withBoardPHIDs(array_keys($boards)) ->withObjectPHIDs($object_phids) ->execute(); $positions = msort($positions, 'getOrderingKey'); $positions = mgroup($positions, 'getBoardPHID'); return $positions; } private function layoutBoard( $board, array $columns, array $positions) { $viewer = $this->getViewer(); $board_phid = $board->getPHID(); $position_groups = mgroup($positions, 'getObjectPHID'); $layout = array(); foreach ($columns as $column) { $column_phid = $column->getPHID(); $layout[$column_phid] = array(); if ($column->isDefaultColumn()) { $default_phid = $column_phid; } } // Find all the columns which are proxies for other objects. $proxy_map = array(); foreach ($columns as $column) { $proxy_phid = $column->getProxyPHID(); if ($proxy_phid) { $proxy_map[$proxy_phid] = $column->getPHID(); } } $object_phids = $this->getObjectPHIDs(); // If we have proxies, we need to force cards into the correct proxy // columns. - if ($proxy_map) { + if ($proxy_map && $object_phids) { $edge_query = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs($object_phids) ->withEdgeTypes( array( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, )); $edge_query->execute(); $project_phids = $edge_query->getDestinationPHIDs(); $project_phids = array_fuse($project_phids); } else { $project_phids = array(); } if ($project_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withPHIDs($project_phids) ->execute(); $projects = mpull($projects, null, 'getPHID'); } else { $projects = array(); } // Build a map from every project that any task is tagged with to the // ancestor project which has a column on this board, if one exists. $ancestor_map = array(); foreach ($projects as $phid => $project) { if (isset($proxy_map[$phid])) { $ancestor_map[$phid] = $proxy_map[$phid]; } else { $seen = array($phid); foreach ($project->getAncestorProjects() as $ancestor) { $ancestor_phid = $ancestor->getPHID(); $seen[] = $ancestor_phid; if (isset($proxy_map[$ancestor_phid])) { foreach ($seen as $project_phid) { $ancestor_map[$project_phid] = $proxy_map[$ancestor_phid]; } } } } } foreach ($object_phids as $object_phid) { $positions = idx($position_groups, $object_phid, array()); // First, check for objects that have corresponding proxy columns. We're // going to overwrite normal column positions if a tag belongs to a proxy // column, since you can't be in normal columns if you're in proxy // columns. $proxy_hits = array(); if ($proxy_map) { $object_project_phids = $edge_query->getDestinationPHIDs( array( $object_phid, )); foreach ($object_project_phids as $project_phid) { if (isset($ancestor_map[$project_phid])) { $proxy_hits[] = $ancestor_map[$project_phid]; } } } if ($proxy_hits) { // TODO: For now, only one column hit is permissible. $proxy_hits = array_slice($proxy_hits, 0, 1); $proxy_hits = array_fuse($proxy_hits); // Check the object positions: we hope to find a position in each // column the object should be part of. We're going to drop any // invalid positions and create new positions where positions are // missing. foreach ($positions as $key => $position) { $column_phid = $position->getColumnPHID(); if (isset($proxy_hits[$column_phid])) { // Valid column, mark the position as found. unset($proxy_hits[$column_phid]); } else { // Invalid column, ignore the position. unset($positions[$key]); } } // Create new positions for anything we haven't found. foreach ($proxy_hits as $proxy_hit) { $new_position = id(new PhabricatorProjectColumnPosition()) ->setBoardPHID($board_phid) ->setColumnPHID($proxy_hit) ->setObjectPHID($object_phid) ->setSequence(0); $this->addQueue[] = $new_position; $positions[] = $new_position; } } else { // Ignore any positions in columns which no longer exist. We don't // actively destory them because the rest of the code ignores them and // there's no real need to destroy the data. foreach ($positions as $key => $position) { $column_phid = $position->getColumnPHID(); if (empty($columns[$column_phid])) { unset($positions[$key]); } } // If the object has no position, put it on the default column. if (!$positions) { $new_position = id(new PhabricatorProjectColumnPosition()) ->setBoardPHID($board_phid) ->setColumnPHID($default_phid) ->setObjectPHID($object_phid) ->setSequence(0); $this->addQueue[] = $new_position; $positions = array( $new_position, ); } } foreach ($positions as $position) { $column_phid = $position->getColumnPHID(); $layout[$column_phid][$object_phid] = $position; } } foreach ($layout as $column_phid => $map) { $map = msort($map, 'getOrderingKey'); $layout[$column_phid] = $map; foreach ($map as $object_phid => $position) { $this->objectColumnMap[$board_phid][$object_phid][] = $column_phid; } } $this->boardLayout[$board_phid] = $layout; } }