diff --git a/src/applications/project/engine/PhabricatorBoardLayoutEngine.php b/src/applications/project/engine/PhabricatorBoardLayoutEngine.php index e614ec2f94..83fb943b76 100644 --- a/src/applications/project/engine/PhabricatorBoardLayoutEngine.php +++ b/src/applications/project/engine/PhabricatorBoardLayoutEngine.php @@ -1,595 +1,595 @@ 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; } /** * Fetch all boards, even if the board is disabled. */ public function setFetchAllBoards($fetch_all) { $this->fetchAllBoards = $fetch_all; return $this; } public function getFetchAllBoards() { return $this->fetchAllBoards; } 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 getColumnObjectPositions($board_phid, $column_phid) { $columns = idx($this->boardLayout, $board_phid, array()); return idx($columns, $column_phid, array()); } public function getColumnObjectPHIDs($board_phid, $column_phid) { $positions = $this->getColumnObjectPositions($board_phid, $column_phid); 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 queueAddPosition( $board_phid, $column_phid, $object_phid, array $after_phids, array $before_phids) { $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); } if (!$positions) { $object_position->setSequence(0); } else { // The user's view of the board may fall out of date, so they might // try to drop a card under a different card which is no longer where // they thought it was. // When this happens, we perform the move anyway, since this is almost // certainly what users want when interacting with the UI. We'l try to // fall back to another nearby card if the client provided us one. If // we don't find any of the cards the client specified in the column, // we'll just move the card to the default position. $search_phids = array(); foreach ($after_phids as $after_phid) { $search_phids[] = array($after_phid, false); } foreach ($before_phids as $before_phid) { $search_phids[] = array($before_phid, true); } // This makes us fall back to the default position if we fail every // candidate position. The default position counts as a "before" position // because we want to put the new card at the top of the column. $search_phids[] = array(null, true); $found = false; foreach ($search_phids as $search_position) { list($relative_phid, $is_before) = $search_position; 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 ($found) { break; } } } $this->addQueue[] = $object_position; $positions[$object_phid] = $object_position; - $positions = msort($positions, 'getOrderingKey'); + $positions = msortv($positions, 'newColumnPositionOrderVector'); $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 %LQ ON DUPLICATE KEY UPDATE sequence = VALUES(sequence)', $table->getTableName(), $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'); if (!$this->fetchAllBoards) { 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)) ->needTriggers(true) ->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()); if ($board_columns) { $next_sequence = last($board_columns)->getSequence() + 1; } else { $next_sequence = 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 = msortv($positions, 'newColumnPositionOrderVector'); $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(); $default_phid = null; 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 && $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]; } } } } } $view_sequence = 1; 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) ->setViewSequence($view_sequence++); $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 // one exists. if (!$positions && $default_phid) { $new_position = id(new PhabricatorProjectColumnPosition()) ->setBoardPHID($board_phid) ->setColumnPHID($default_phid) ->setObjectPHID($object_phid) ->setSequence(0) ->setViewSequence($view_sequence++); $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'); + $map = msortv($map, 'newColumnPositionOrderVector'); $layout[$column_phid] = $map; foreach ($map as $object_phid => $position) { $this->objectColumnMap[$board_phid][$object_phid][] = $column_phid; } } $this->boardLayout[$board_phid] = $layout; } } diff --git a/src/applications/project/engine/PhabricatorBoardResponseEngine.php b/src/applications/project/engine/PhabricatorBoardResponseEngine.php index 96a6d8c457..81e56d2116 100644 --- a/src/applications/project/engine/PhabricatorBoardResponseEngine.php +++ b/src/applications/project/engine/PhabricatorBoardResponseEngine.php @@ -1,272 +1,279 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setBoardPHID($board_phid) { $this->boardPHID = $board_phid; return $this; } public function getBoardPHID() { return $this->boardPHID; } public function setObjects(array $objects) { $this->objects = $objects; return $this; } public function getObjects() { return $this->objects; } public function setVisiblePHIDs(array $visible_phids) { $this->visiblePHIDs = $visible_phids; return $this; } public function getVisiblePHIDs() { return $this->visiblePHIDs; } public function setUpdatePHIDs(array $update_phids) { $this->updatePHIDs = $update_phids; return $this; } public function getUpdatePHIDs() { return $this->updatePHIDs; } public function setOrdering(PhabricatorProjectColumnOrder $ordering) { $this->ordering = $ordering; return $this; } public function getOrdering() { return $this->ordering; } public function setSounds(array $sounds) { $this->sounds = $sounds; return $this; } public function getSounds() { return $this->sounds; } public function buildResponse() { $viewer = $this->getViewer(); $board_phid = $this->getBoardPHID(); $ordering = $this->getOrdering(); $update_phids = $this->getUpdatePHIDs(); $update_phids = array_fuse($update_phids); $visible_phids = $this->getVisiblePHIDs(); $visible_phids = array_fuse($visible_phids); $all_phids = $update_phids + $visible_phids; // Load all the other tasks that are visible in the affected columns and // perform layout for them. if ($this->objects !== null) { $all_objects = $this->getObjects(); $all_objects = mpull($all_objects, null, 'getPHID'); } else { $all_objects = id(new ManiphestTaskQuery()) ->setViewer($viewer) ->withPHIDs($all_phids) ->execute(); $all_objects = mpull($all_objects, null, 'getPHID'); } + // NOTE: The board layout engine is sensitive to PHID input order, and uses + // the input order as a component of the "natural" column ordering if no + // explicit ordering is specified. Rearrange the PHIDs in ID order. + + $all_objects = msort($all_objects, 'getID'); + $ordered_phids = mpull($all_objects, 'getPHID'); + $layout_engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($board_phid)) - ->setObjectPHIDs($all_phids) + ->setObjectPHIDs($ordered_phids) ->executeLayout(); $natural = array(); $update_columns = array(); foreach ($update_phids as $update_phid) { $update_columns += $layout_engine->getObjectColumns( $board_phid, $update_phid); } foreach ($update_columns as $column_phid => $column) { $column_object_phids = $layout_engine->getColumnObjectPHIDs( $board_phid, $column_phid); $natural[$column_phid] = array_values($column_object_phids); } if ($ordering) { $vectors = $ordering->getSortVectorsForObjects($all_objects); $header_keys = $ordering->getHeaderKeysForObjects($all_objects); $headers = $ordering->getHeadersForObjects($all_objects); $headers = mpull($headers, 'toDictionary'); } else { $vectors = array(); $header_keys = array(); $headers = array(); } $templates = $this->newCardTemplates(); $cards = array(); foreach ($all_objects as $card_phid => $object) { $card = array( 'vectors' => array(), 'headers' => array(), 'properties' => array(), 'nodeHTMLTemplate' => null, ); if ($ordering) { $order_key = $ordering->getColumnOrderKey(); $vector = idx($vectors, $card_phid); if ($vector !== null) { $card['vectors'][$order_key] = $vector; } $header = idx($header_keys, $card_phid); if ($header !== null) { $card['headers'][$order_key] = $header; } $card['properties'] = self::newTaskProperties($object); } if (isset($templates[$card_phid])) { $card['nodeHTMLTemplate'] = hsprintf('%s', $templates[$card_phid]); $card['update'] = true; } else { $card['update'] = false; } $card['vectors'] = (object)$card['vectors']; $card['headers'] = (object)$card['headers']; $card['properties'] = (object)$card['properties']; $cards[$card_phid] = $card; } // Mark cards which are currently visible on the client but not visible // on the board on the server for removal from the client view of the // board state. foreach ($visible_phids as $card_phid) { if (!isset($cards[$card_phid])) { $cards[$card_phid] = array( 'remove' => true, ); } } $payload = array( 'columnMaps' => $natural, 'cards' => $cards, 'headers' => $headers, 'sounds' => $this->getSounds(), ); return id(new AphrontAjaxResponse()) ->setContent($payload); } public static function newTaskProperties($task) { return array( 'points' => (double)$task->getPoints(), 'status' => $task->getStatus(), 'priority' => (int)$task->getPriority(), 'owner' => $task->getOwnerPHID(), ); } private function loadExcludedProjectPHIDs() { $viewer = $this->getViewer(); $board_phid = $this->getBoardPHID(); $exclude_phids = array($board_phid); $descendants = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withAncestorProjectPHIDs($exclude_phids) ->execute(); foreach ($descendants as $descendant) { $exclude_phids[] = $descendant->getPHID(); } return array_fuse($exclude_phids); } private function newCardTemplates() { $viewer = $this->getViewer(); $update_phids = $this->getUpdatePHIDs(); if (!$update_phids) { return array(); } $update_phids = array_fuse($update_phids); if ($this->objects === null) { $objects = id(new ManiphestTaskQuery()) ->setViewer($viewer) ->withPHIDs($update_phids) ->needProjectPHIDs(true) ->execute(); } else { $objects = $this->getObjects(); $objects = mpull($objects, null, 'getPHID'); $objects = array_select_keys($objects, $update_phids); } if (!$objects) { return array(); } $excluded_phids = $this->loadExcludedProjectPHIDs(); $rendering_engine = id(new PhabricatorBoardRenderingEngine()) ->setViewer($viewer) ->setObjects($objects) ->setExcludedProjectPHIDs($excluded_phids); $templates = array(); foreach ($objects as $object) { $object_phid = $object->getPHID(); $card = $rendering_engine->renderCard($object_phid); $item = $card->getItem(); $template = hsprintf('%s', $item); $templates[$object_phid] = $template; } return $templates; } } diff --git a/src/applications/project/storage/PhabricatorProjectColumnPosition.php b/src/applications/project/storage/PhabricatorProjectColumnPosition.php index 0bd9be6d4a..1a094dec37 100644 --- a/src/applications/project/storage/PhabricatorProjectColumnPosition.php +++ b/src/applications/project/storage/PhabricatorProjectColumnPosition.php @@ -1,90 +1,89 @@ false, self::CONFIG_COLUMN_SCHEMA => array( 'sequence' => 'uint32', ), self::CONFIG_KEY_SCHEMA => array( 'boardPHID' => array( 'columns' => array('boardPHID', 'columnPHID', 'objectPHID'), 'unique' => true, ), 'objectPHID' => array( 'columns' => array('objectPHID', 'boardPHID'), ), 'boardPHID_2' => array( 'columns' => array('boardPHID', 'columnPHID', 'sequence'), ), ), ) + parent::getConfiguration(); } public function getColumn() { return $this->assertAttached($this->column); } public function attachColumn(PhabricatorProjectColumn $column) { $this->column = $column; return $this; } public function setViewSequence($view_sequence) { $this->viewSequence = $view_sequence; return $this; } - public function getOrderingKey() { + public function newColumnPositionOrderVector() { // We're ordering both real positions and "virtual" positions which we have // created but not saved yet. // Low sequence numbers go above high sequence numbers. Virtual positions // will have sequence number 0. // High virtual sequence numbers go above low virtual sequence numbers. // The layout engine gets objects in ID order, and this puts them in // reverse ID order. // High IDs go above low IDs. // Broadly, this collectively makes newly added stuff float to the top. - return sprintf( - '~%012d%012d%012d', - $this->getSequence(), - ((1 << 31) - $this->viewSequence), - ((1 << 31) - $this->getID())); + return id(new PhutilSortVector()) + ->addInt($this->getSequence()) + ->addInt(-1 * $this->viewSequence) + ->addInt(-1 * $this->getID()); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } }