diff --git a/src/applications/config/controller/PhabricatorConfigWelcomeController.php b/src/applications/config/controller/PhabricatorConfigWelcomeController.php index a3ef29bbc8..c7ddad5394 100644 --- a/src/applications/config/controller/PhabricatorConfigWelcomeController.php +++ b/src/applications/config/controller/PhabricatorConfigWelcomeController.php @@ -1,411 +1,411 @@ getViewer(); $nav = $this->buildSideNavView(); $nav->selectFilter('welcome/'); $title = pht('Welcome'); $crumbs = $this ->buildApplicationCrumbs() ->addTextCrumb(pht('Welcome')); $nav->setCrumbs($crumbs); $nav->appendChild($this->buildWelcomeScreen($request)); return $this->buildApplicationPage( $nav, array( 'title' => $title, )); } public function buildWelcomeScreen(AphrontRequest $request) { $viewer = $request->getUser(); $this->requireResource('config-welcome-css'); $content = pht( "=== Install Phabricator ===\n\n". "You have successfully installed Phabricator. This screen will guide ". "you through configuration and orientation. ". "These steps are optional, and you can go through them in any order. ". "If you want to get back to this screen later on, you can find it in ". "the **Config** application under **Welcome Screen**."); $setup = array(); $setup[] = $this->newItem( $request, 'fa-check-square-o green', $content); $issues_resolved = !PhabricatorSetupCheck::getOpenSetupIssueKeys(); $setup_href = PhabricatorEnv::getURI('/config/issue/'); if ($issues_resolved) { $content = pht( "=== Resolve Setup Issues ===\n\n". "You've resolved (or ignored) all outstanding setup issues. ". "You can review issues in the **Config** application, under ". "**[[ %s | Setup Issues ]]**.", $setup_href); $icon = 'fa-check-square-o green'; } else { $content = pht( "=== Resolve Setup Issues ===\n\n". "You have some unresolved setup issues to take care of. Click ". "the link in the yellow banner at the top of the screen to see ". "them, or find them in the **Config** application under ". "**[[ %s | Setup Issues ]]**. ". "Although most setup issues should be resolved, sometimes an issue ". "is not applicable to an install. ". "If you don't intend to fix a setup issue (or don't want to fix ". "it for now), you can use the \"Ignore\" action to mark it as ". "something you don't plan to deal with.", $setup_href); $icon = 'fa-warning red'; } $setup[] = $this->newItem( $request, $icon, $content); $configs = id(new PhabricatorAuthProviderConfigQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->execute(); $auth_href = PhabricatorEnv::getURI('/auth/'); $have_auth = (bool)$configs; if ($have_auth) { $content = pht( "=== Login and Registration ===\n\n". "You've configured at least one authentication provider, so users ". "can register or log in. ". "To configure more providers or adjust settings, use the ". "**[[ %s | Auth Application ]]**.", $auth_href); $icon = 'fa-check-square-o green'; } else { $content = pht( "=== Login and Registration ===\n\n". "You haven't configured any authentication providers yet. ". "Authentication providers allow users to register accounts and ". "log in to Phabricator. You can configure Phabricator to accept ". "credentials like username and password, LDAP, or Google OAuth. ". "You can configure authentication using the ". "**[[ %s | Auth Application ]]**.", $auth_href); $icon = 'fa-warning red'; } $setup[] = $this->newItem( $request, $icon, $content); $config_href = PhabricatorEnv::getURI('/config/'); // Just load any config value at all; if one exists the install has figured // out how to configure things. $have_config = (bool)id(new PhabricatorConfigEntry())->loadAllWhere( '1 = 1 LIMIT 1'); if ($have_config) { $content = pht( "=== Configure Phabricator Settings ===\n\n". "You've configured at least one setting from the web interface. ". "To configure more settings later, use the ". "**[[ %s | Config Application ]]**.", $config_href); $icon = 'fa-check-square-o green'; } else { $content = pht( "=== Configure Phabricator Settings ===\n\n". 'Many aspects of Phabricator are configurable. To explore and '. 'adjust settings, use the **[[ %s | Config Application ]]**.', $config_href); $icon = 'fa-info-circle'; } $setup[] = $this->newItem( $request, $icon, $content); $settings_href = PhabricatorEnv::getURI('/settings/'); $prefs = $viewer->loadPreferences()->getPreferences(); $have_settings = !empty($prefs); if ($have_settings) { $content = pht( "=== Adjust Account Settings ===\n\n". "You've adjusted at least one setting on your account. ". "To make more adjustments, visit the ". "**[[ %s | Settings Application ]]**.", $settings_href); $icon = 'fa-check-square-o green'; } else { $content = pht( "=== Adjust Account Settings ===\n\n". 'You can configure settings for your account by clicking the '. 'wrench icon in the main menu bar, or visiting the '. '**[[ %s | Settings Application ]]** directly.', $settings_href); $icon = 'fa-info-circle'; } $setup[] = $this->newItem( $request, $icon, $content); $dashboard_href = PhabricatorEnv::getURI('/dashboard/'); $have_dashboard = (bool)PhabricatorDashboardInstall::getDashboard( $viewer, PhabricatorHomeApplication::DASHBOARD_DEFAULT, 'PhabricatorHomeApplication'); if ($have_dashboard) { $content = pht( "=== Customize Home Page ===\n\n". "You've installed a default dashboard to replace this welcome screen ". "on the home page. ". "You can still visit the welcome screen here at any time if you ". "have steps you want to complete later, or if you feel lonely. ". "If you've changed your mind about the dashboard you installed, ". "you can install a different default dashboard with the ". "**[[ %s | Dashboards Application ]]**.", $dashboard_href); $icon = 'fa-check-square-o green'; } else { $content = pht( "=== Customize Home Page ===\n\n". "When you're done setting things up, you can create a custom ". "dashboard and install it. Your dashboard will replace this ". "welcome screen on the Phabricator home page. ". "Dashboards can show users the information that's most important to ". "your organization. You can configure them to display things like: ". "a custom welcome message, a feed of recent activity, or a list of ". "open tasks, waiting reviews, recent commits, and so on. ". "After you install a default dashboard, it will replace this page. ". "You can find this page later by visiting the **Config** ". "application, under **Welcome Page**. ". "To get started building a dashboard, use the ". "**[[ %s | Dashboards Application ]]**. ", $dashboard_href); $icon = 'fa-info-circle'; } $setup[] = $this->newItem( $request, $icon, $content); $apps_href = PhabricatorEnv::getURI('/applications/'); $content = pht( "=== Explore Applications ===\n\n". "Phabricator is a large suite of applications that work together to ". "help you develop software, manage tasks, and communicate. A few of ". "the most commonly used applications are pinned to the left navigation ". "bar by default.\n\n". "To explore all of the Phabricator applications, adjust settings, or ". "uninstall applications you don't plan to use, visit the ". "**[[ %s | Applications Application ]]**. You can also click the ". "**Applications** button in the left navigation menu, or search for an ". "application by name in the main menu bar. ", $apps_href); $explore = array(); $explore[] = $this->newItem( $request, 'fa-globe', $content); // TODO: Restore some sort of "Support" link here, but just nuke it for // now as we figure stuff out. $differential_uri = PhabricatorEnv::getURI('/differential/'); $differential_create_uri = PhabricatorEnv::getURI( '/differential/diff/create/'); $differential_all_uri = PhabricatorEnv::getURI('/differential/query/all/'); $differential_user_guide = PhabricatorEnv::getDoclink( 'Differential User Guide'); $differential_vs_uri = PhabricatorEnv::getDoclink( 'User Guide: Review vs Audit'); $quick = array(); $quick[] = $this->newItem( $request, 'fa-gear', pht( "=== Quick Start: Code Review ===\n\n". "Review code with **[[ %s | Differential ]]**. ". "Engineers can use Differential to share, review, and approve ". "changes to source code. ". "To get started with code review:\n\n". " - **[[ %s | Create a Revision ]]** //(Copy and paste a diff from ". " the command line into the web UI to quickly get a feel for ". " review.)//\n". " - **[[ %s | View All Revisions ]]**\n\n". "For more information, see these articles in the documentation:\n\n". " - **[[ %s | Differential User Guide ]]**, for a general overview ". " of Differential.\n". " - **[[ %s | User Guide: Review vs Audit ]]**, for a discussion ". " of different code review workflows.", $differential_uri, $differential_create_uri, $differential_all_uri, $differential_user_guide, $differential_vs_uri)); $maniphest_uri = PhabricatorEnv::getURI('/maniphest/'); - $maniphest_create_uri = PhabricatorEnv::getURI('/maniphest/task/create/'); + $maniphest_create_uri = PhabricatorEnv::getURI('/maniphest/editpro/'); $maniphest_all_uri = PhabricatorEnv::getURI('/maniphest/query/all/'); $quick[] = $this->newItem( $request, 'fa-anchor', pht( "=== Quick Start: Bugs and Tasks ===\n\n". "Track bugs and tasks in Phabricator with ". "**[[ %s | Maniphest ]]**. ". "Users in all roles can use Maniphest to manage current and ". "planned work and to track bugs and issues. ". "To get started with bugs and tasks:\n\n". " - **[[ %s | Create a Task ]]**\n". " - **[[ %s | View All Tasks ]]**\n", $maniphest_uri, $maniphest_create_uri, $maniphest_all_uri)); $pholio_uri = PhabricatorEnv::getURI('/pholio/'); $pholio_create_uri = PhabricatorEnv::getURI('/pholio/new/'); $pholio_all_uri = PhabricatorEnv::getURI('/pholio/query/all/'); $quick[] = $this->newItem( $request, 'fa-camera-retro', pht( "=== Quick Start: Design Review ===\n\n". "Review proposed designs with **[[ %s | Pholio ]]**. ". "Designers can use Pholio to share images of what they're working on ". "and show off things they've made. ". "To get started with design review:\n\n". " - **[[ %s | Create a Mock ]]**\n". " - **[[ %s | View All Mocks ]]**", $pholio_uri, $pholio_create_uri, $pholio_all_uri)); $diffusion_uri = PhabricatorEnv::getURI('/diffusion/'); $diffusion_create_uri = PhabricatorEnv::getURI('/diffusion/create/'); $diffusion_all_uri = PhabricatorEnv::getURI('/diffusion/query/all/'); $diffusion_user_guide = PhabricatorEnv::getDoclink('Diffusion User Guide'); $diffusion_setup_guide = PhabricatorEnv::getDoclink( 'Diffusion User Guide: Repository Hosting'); $quick[] = $this->newItem( $request, 'fa-code', pht( "=== Quick Start: Repositories ===\n\n". "Manage and browse source code repositories with ". "**[[ %s | Diffusion ]]**. ". "Engineers can use Diffusion to browse and audit source code. ". "You can configure Phabricator to host repositories, or have it ". "track existing repositories hosted elsewhere (like GitHub, ". "Bitbucket, or an internal server). ". "To get started with repositories:\n\n". " - **[[ %s | Create a New Repository ]]**\n". " - **[[ %s | View All Repositories ]]**\n\n". "For more information, see these articles in the documentation:\n\n". " - **[[ %s | Diffusion User Guide ]]**, for a general overview of ". " Diffusion.\n". " - **[[ %s | Diffusion User Guide: Repository Hosting ]]**, ". " for instructions on configuring repository hosting.\n\n". "Phabricator supports Git, Mercurial and Subversion.", $diffusion_uri, $diffusion_create_uri, $diffusion_all_uri, $diffusion_user_guide, $diffusion_setup_guide)); $header = id(new PHUIHeaderView()) ->setHeader(pht('Welcome to Phabricator')); $setup_header = PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff()) ->setContent(pht('=Setup and Configuration')), 'default', $viewer); $explore_header = PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff()) ->setContent(pht('=Explore Phabricator')), 'default', $viewer); $quick_header = PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff()) ->setContent(pht('=Quick Start Guides')), 'default', $viewer); return id(new PHUIDocumentView()) ->setHeader($header) ->setFluid(true) ->appendChild($setup_header) ->appendChild($setup) ->appendChild($explore_header) ->appendChild($explore) ->appendChild($quick_header) ->appendChild($quick); } private function newItem(AphrontRequest $request, $icon, $content) { $viewer = $request->getUser(); $icon = id(new PHUIIconView()) ->setIconFont($icon.' fa-2x'); $content = PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff())->setContent($content), 'default', $viewer); $icon = phutil_tag( 'div', array( 'class' => 'config-welcome-icon', ), $icon); $content = phutil_tag( 'div', array( 'class' => 'config-welcome-content', ), $content); $view = phutil_tag( 'div', array( 'class' => 'config-welcome-box grouped', ), array( $icon, $content, )); return $view; } } diff --git a/src/applications/maniphest/editor/ManiphestEditEngine.php b/src/applications/maniphest/editor/ManiphestEditEngine.php index c588201287..02506f295a 100644 --- a/src/applications/maniphest/editor/ManiphestEditEngine.php +++ b/src/applications/maniphest/editor/ManiphestEditEngine.php @@ -1,311 +1,326 @@ getViewer()); } protected function newObjectQuery() { return id(new ManiphestTaskQuery()); } protected function getObjectCreateTitleText($object) { return pht('Create New Task'); } protected function getObjectEditTitleText($object) { return pht('Edit %s %s', $object->getMonogram(), $object->getTitle()); } protected function getObjectEditShortText($object) { return $object->getMonogram(); } protected function getObjectCreateShortText() { return pht('Create Task'); } protected function getCommentViewHeaderText($object) { $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); if (!$is_serious) { return pht('Weigh In'); } return parent::getCommentViewHeaderText($object); } protected function getObjectViewURI($object) { return '/'.$object->getMonogram(); } protected function buildCustomEditFields($object) { $status_map = $this->getTaskStatusMap($object); $priority_map = $this->getTaskPriorityMap($object); if ($object->isClosed()) { $priority_label = null; $default_status = ManiphestTaskStatus::getDefaultStatus(); } else { $priority_label = pht('Change Priority'); $default_status = ManiphestTaskStatus::getDefaultClosedStatus(); } if ($object->getOwnerPHID()) { $owner_value = array($object->getOwnerPHID()); } else { $owner_value = array($this->getViewer()->getPHID()); } return array( id(new PhabricatorHandlesEditField()) ->setKey('parent') ->setLabel(pht('Parent Task')) ->setDescription(pht('Task to make this a subtask of.')) ->setAliases(array('parentPHID')) ->setTransactionType(ManiphestTransaction::TYPE_PARENT) - ->setSingleValue(null), + ->setHandleParameterType(new ManiphestTaskListHTTPParameterType()) + ->setSingleValue(null) + ->setIsReorderable(false) + ->setIsDefaultable(false) + ->setIsLockable(false), + id(new PhabricatorHandlesEditField()) + ->setKey('column') + ->setLabel(pht('Column')) + ->setDescription(pht('Workboard column to create this task into.')) + ->setAliases(array('columnPHID')) + ->setTransactionType(ManiphestTransaction::TYPE_COLUMN) + ->setSingleValue(null) + ->setIsInvisible(true) + ->setIsReorderable(false) + ->setIsDefaultable(false) + ->setIsLockable(false), id(new PhabricatorTextEditField()) ->setKey('title') ->setLabel(pht('Title')) ->setDescription(pht('Name of the task.')) ->setTransactionType(ManiphestTransaction::TYPE_TITLE) ->setIsRequired(true) ->setValue($object->getTitle()), id(new PhabricatorUsersEditField()) ->setKey('owner') ->setAliases(array('ownerPHID', 'assign', 'assigned')) ->setLabel(pht('Assigned To')) ->setDescription(pht('User who is responsible for the task.')) ->setTransactionType(ManiphestTransaction::TYPE_OWNER) ->setIsCopyable(true) ->setSingleValue($object->getOwnerPHID()) ->setCommentActionLabel(pht('Assign / Claim')) ->setCommentActionDefaultValue($owner_value), id(new PhabricatorSelectEditField()) ->setKey('status') ->setLabel(pht('Status')) ->setDescription(pht('Status of the task.')) ->setTransactionType(ManiphestTransaction::TYPE_STATUS) ->setIsCopyable(true) ->setValue($object->getStatus()) ->setOptions($status_map) ->setCommentActionLabel(pht('Change Status')) ->setCommentActionDefaultValue($default_status), id(new PhabricatorSelectEditField()) ->setKey('priority') ->setLabel(pht('Priority')) ->setDescription(pht('Priority of the task.')) ->setTransactionType(ManiphestTransaction::TYPE_PRIORITY) ->setIsCopyable(true) ->setValue($object->getPriority()) ->setOptions($priority_map) ->setCommentActionLabel($priority_label), id(new PhabricatorRemarkupEditField()) ->setKey('description') ->setLabel(pht('Description')) ->setDescription(pht('Task description.')) ->setTransactionType(ManiphestTransaction::TYPE_DESCRIPTION) ->setValue($object->getDescription()), ); } protected function getEditorURI() { // TODO: Remove when cutting over. return $this->getApplication()->getApplicationURI('editpro/'); } private function getTaskStatusMap(ManiphestTask $task) { $status_map = ManiphestTaskStatus::getTaskStatusMap(); $current_status = $task->getStatus(); // If the current status is something we don't recognize (maybe an older // status which was deleted), put a dummy entry in the status map so that // saving the form doesn't destroy any data by accident. if (idx($status_map, $current_status) === null) { $status_map[$current_status] = pht('', $current_status); } $dup_status = ManiphestTaskStatus::getDuplicateStatus(); foreach ($status_map as $status => $status_name) { // Always keep the task's current status. if ($status == $current_status) { continue; } // Don't allow tasks to be changed directly into "Closed, Duplicate" // status. Instead, you have to merge them. See T4819. if ($status == $dup_status) { unset($status_map[$status]); continue; } // Don't let new or existing tasks be moved into a disabled status. if (ManiphestTaskStatus::isDisabledStatus($status)) { unset($status_map[$status]); continue; } } return $status_map; } private function getTaskPriorityMap(ManiphestTask $task) { $priority_map = ManiphestTaskPriority::getTaskPriorityMap(); $current_priority = $task->getPriority(); // If the current value isn't a legitimate one, put it in the dropdown // anyway so saving the form doesn't cause a side effects. if (idx($priority_map, $current_priority) === null) { $priority_map[$current_priority] = pht( '', $current_priority); } foreach ($priority_map as $priority => $priority_name) { // Always keep the current priority. if ($priority == $current_priority) { continue; } if (ManiphestTaskPriority::isDisabledPriority($priority)) { unset($priority_map[$priority]); continue; } } return $priority_map; } protected function newEditResponse( AphrontRequest $request, $object, array $xactions) { if ($request->isAjax()) { // Reload the task to make sure we pick up the final task state. $viewer = $this->getViewer(); $task = id(new ManiphestTaskQuery()) ->setViewer($viewer) ->withIDs(array($object->getID())) ->needSubscriberPHIDs(true) ->needProjectPHIDs(true) ->executeOne(); switch ($request->getStr('responseType')) { case 'card': return $this->buildCardResponse($task); default: return $this->buildListResponse($task); } } - return parent::newEditResponse(); + return parent::newEditResponse($request, $object, $xactions); } private function buildListResponse(ManiphestTask $task) { $controller = $this->getController(); $payload = array( 'tasks' => $controller->renderSingleTask($task), 'data' => array(), ); return id(new AphrontAjaxResponse())->setContent($payload); } private function buildCardResponse(ManiphestTask $task) { $controller = $this->getController(); $request = $controller->getRequest(); $viewer = $request->getViewer(); $column_phid = $request->getStr('columnPHID'); $order = $request->getStr('order'); $column = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withPHIDs(array($column_phid)) ->executeOne(); if (!$column) { return new Aphront404Response(); } // If the workboard's project has been removed from the card's project // list, we are going to remove it from the board completely. $project_map = array_fuse($task->getProjectPHIDs()); $remove_card = empty($project_map[$column->getProjectPHID()]); $positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($viewer) ->withColumns(array($column)) ->execute(); $task_phids = mpull($positions, 'getObjectPHID'); $column_tasks = id(new ManiphestTaskQuery()) ->setViewer($viewer) ->withPHIDs($task_phids) ->execute(); if ($order == PhabricatorProjectColumn::ORDER_NATURAL) { // TODO: This is a little bit awkward, because PHP and JS use // slightly different sort order parameters to achieve the same // effect. It would be good to unify this a bit at some point. $sort_map = array(); foreach ($positions as $position) { $sort_map[$position->getObjectPHID()] = array( -$position->getSequence(), $position->getID(), ); } } else { $sort_map = mpull( $column_tasks, 'getPrioritySortVector', 'getPHID'); } $data = array( 'removeFromBoard' => $remove_card, 'sortMap' => $sort_map, ); // TODO: This should just use HandlePool once we get through the EditEngine // transition. $owner = null; if ($task->getOwnerPHID()) { $owner = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs(array($task->getOwnerPHID())) ->executeOne(); } $tasks = id(new ProjectBoardTaskCard()) ->setViewer($viewer) ->setTask($task) ->setOwner($owner) ->setCanEdit(true) ->getItem(); $payload = array( 'tasks' => $tasks, 'data' => $data, ); return id(new AphrontAjaxResponse())->setContent($payload); } } diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index fc05c3dff3..037286244a 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -1,864 +1,953 @@ getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: if ($this->getIsNewObject()) { return null; } return (int)$object->getPriority(); case ManiphestTransaction::TYPE_STATUS: if ($this->getIsNewObject()) { return null; } return $object->getStatus(); case ManiphestTransaction::TYPE_TITLE: if ($this->getIsNewObject()) { return null; } return $object->getTitle(); case ManiphestTransaction::TYPE_DESCRIPTION: if ($this->getIsNewObject()) { return null; } return $object->getDescription(); case ManiphestTransaction::TYPE_OWNER: return nonempty($object->getOwnerPHID(), null); case ManiphestTransaction::TYPE_PROJECT_COLUMN: // These are pre-populated. return $xaction->getOldValue(); case ManiphestTransaction::TYPE_SUBPRIORITY: return $object->getSubpriority(); case ManiphestTransaction::TYPE_MERGED_INTO: case ManiphestTransaction::TYPE_MERGED_FROM: return null; case ManiphestTransaction::TYPE_PARENT: + case ManiphestTransaction::TYPE_COLUMN: return null; } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: return (int)$xaction->getNewValue(); case ManiphestTransaction::TYPE_OWNER: return nonempty($xaction->getNewValue(), null); case ManiphestTransaction::TYPE_STATUS: case ManiphestTransaction::TYPE_TITLE: case ManiphestTransaction::TYPE_DESCRIPTION: case ManiphestTransaction::TYPE_SUBPRIORITY: case ManiphestTransaction::TYPE_PROJECT_COLUMN: case ManiphestTransaction::TYPE_MERGED_INTO: case ManiphestTransaction::TYPE_MERGED_FROM: case ManiphestTransaction::TYPE_UNBLOCK: return $xaction->getNewValue(); case ManiphestTransaction::TYPE_PARENT: + case ManiphestTransaction::TYPE_COLUMN: return $xaction->getNewValue(); } } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PROJECT_COLUMN: $new_column_phids = $new['columnPHIDs']; $old_column_phids = $old['columnPHIDs']; sort($new_column_phids); sort($old_column_phids); return ($old !== $new); } return parent::transactionHasEffect($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: return $object->setPriority($xaction->getNewValue()); case ManiphestTransaction::TYPE_STATUS: return $object->setStatus($xaction->getNewValue()); case ManiphestTransaction::TYPE_TITLE: return $object->setTitle($xaction->getNewValue()); case ManiphestTransaction::TYPE_DESCRIPTION: return $object->setDescription($xaction->getNewValue()); case ManiphestTransaction::TYPE_OWNER: $phid = $xaction->getNewValue(); // Update the "ownerOrdering" column to contain the full name of the // owner, if the task is assigned. $handle = null; if ($phid) { $handle = id(new PhabricatorHandleQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($phid)) ->executeOne(); } if ($handle) { $object->setOwnerOrdering($handle->getName()); } else { $object->setOwnerOrdering(null); } return $object->setOwnerPHID($phid); case ManiphestTransaction::TYPE_SUBPRIORITY: $object->setSubpriority($xaction->getNewValue()); return; case ManiphestTransaction::TYPE_PROJECT_COLUMN: // these do external (edge) updates return; case ManiphestTransaction::TYPE_MERGED_INTO: $object->setStatus(ManiphestTaskStatus::getDuplicateStatus()); return; case ManiphestTransaction::TYPE_MERGED_FROM: - return; case ManiphestTransaction::TYPE_PARENT: + case ManiphestTransaction::TYPE_COLUMN: return; } } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PARENT: $parent_phid = $xaction->getNewValue(); $parent_type = ManiphestTaskDependsOnTaskEdgeType::EDGECONST; $task_phid = $object->getPHID(); id(new PhabricatorEdgeEditor()) ->addEdge($parent_phid, $parent_type, $task_phid) ->save(); break; case ManiphestTransaction::TYPE_PROJECT_COLUMN: $board_phid = idx($xaction->getNewValue(), 'projectPHID'); if (!$board_phid) { throw new Exception( pht( "Expected '%s' in column transaction.", 'projectPHID')); } $old_phids = idx($xaction->getOldValue(), 'columnPHIDs', array()); $new_phids = idx($xaction->getNewValue(), 'columnPHIDs', array()); if (count($new_phids) !== 1) { throw new Exception( pht( "Expected exactly one '%s' in column transaction.", 'columnPHIDs')); } $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($this->requireActor()) ->withPHIDs($new_phids) ->execute(); $columns = mpull($columns, null, 'getPHID'); $positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($this->requireActor()) ->withObjectPHIDs(array($object->getPHID())) ->withBoardPHIDs(array($board_phid)) ->execute(); $before_phid = idx($xaction->getNewValue(), 'beforePHID'); $after_phid = idx($xaction->getNewValue(), 'afterPHID'); if (!$before_phid && !$after_phid && ($old_phids == $new_phids)) { // If we are not moving the object between columns and also not // reordering the position, this is a move on some other order // (like priority). We can leave the positions untouched and just // bail, there's no work to be done. return; } // Otherwise, we're either moving between columns or adjusting the // object's position in the "natural" ordering, so we do need to update // some rows. // Remove all existing column positions on the board. foreach ($positions as $position) { $position->delete(); } // Add the new column positions. foreach ($new_phids as $phid) { $column = idx($columns, $phid); if (!$column) { throw new Exception( pht('No such column "%s" exists!', $phid)); } // Load the other object positions in the column. Note that we must // skip implicit column creation to avoid generating a new position // if the target column is a backlog column. $other_positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($this->requireActor()) ->withColumns(array($column)) ->withBoardPHIDs(array($board_phid)) ->setSkipImplicitCreate(true) ->execute(); $other_positions = msort($other_positions, 'getOrderingKey'); // Set up the new position object. We're going to figure out the // right sequence number and then persist this object with that // sequence number. $new_position = id(new PhabricatorProjectColumnPosition()) ->setBoardPHID($board_phid) ->setColumnPHID($column->getPHID()) ->setObjectPHID($object->getPHID()); $updates = array(); $sequence = 0; // If we're just dropping this into the column without any specific // position information, put it at the top. if (!$before_phid && !$after_phid) { $new_position->setSequence($sequence)->save(); $sequence++; } foreach ($other_positions as $position) { $object_phid = $position->getObjectPHID(); // If this is the object we're moving before and we haven't // saved yet, insert here. if (($before_phid == $object_phid) && !$new_position->getID()) { $new_position->setSequence($sequence)->save(); $sequence++; } // This object goes here in the sequence; we might need to update // the row. if ($sequence != $position->getSequence()) { $updates[$position->getID()] = $sequence; } $sequence++; // If this is the object we're moving after and we haven't saved // yet, insert here. if (($after_phid == $object_phid) && !$new_position->getID()) { $new_position->setSequence($sequence)->save(); $sequence++; } } // We should have found a place to put it. if (!$new_position->getID()) { throw new Exception( pht('Unable to find a place to insert object on column!')); } // If we changed other objects' column positions, bulk reorder them. if ($updates) { $position = new PhabricatorProjectColumnPosition(); $conn_w = $position->establishConnection('w'); $pairs = array(); foreach ($updates as $id => $sequence) { // 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, $sequence); } queryfx( $conn_w, 'INSERT INTO %T (id, sequence, boardPHID, columnPHID, objectPHID) VALUES %Q ON DUPLICATE KEY UPDATE sequence = VALUES(sequence)', $position->getTableName(), implode(', ', $pairs)); } } break; default: 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 ManiphestTransaction::TYPE_STATUS: $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) { $unblock_xactions = array(); $unblock_xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_UNBLOCK) ->setOldValue(array($object->getPHID() => $old)) ->setNewValue(array($object->getPHID() => $new)); id(new ManiphestTransactionEditor()) ->setActor($this->getActor()) ->setActingAsPHID($this->getActingAsPHID()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($blocked_task, $unblock_xactions); } } } return $xactions; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailSubjectPrefix() { return PhabricatorEnv::getEnvConfig('metamta.maniphest.subject-prefix'); } 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 the tasks a task is blocked by 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}") ->addHeader('Thread-Topic', "T{$id}: ".$object->getOriginalTitle()); } 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_column = ManiphestTransaction::TYPE_PROJECT_COLUMN; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_column) { $new = $xaction->getNewValue(); $project_phid = idx($new, 'projectPHID'); if ($project_phid) { $board_phids[] = $project_phid; } } } 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().'/')); } } return $body; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return $this->shouldSendMail($object, $xactions); } 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 requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { parent::requireCapabilities($object, $xaction); $app_capability_map = array( ManiphestTransaction::TYPE_PRIORITY => ManiphestEditPriorityCapability::CAPABILITY, ManiphestTransaction::TYPE_STATUS => ManiphestEditStatusCapability::CAPABILITY, ManiphestTransaction::TYPE_OWNER => ManiphestEditAssignCapability::CAPABILITY, PhabricatorTransactions::TYPE_EDIT_POLICY => ManiphestEditPoliciesCapability::CAPABILITY, PhabricatorTransactions::TYPE_VIEW_POLICY => ManiphestEditPoliciesCapability::CAPABILITY, ); $transaction_type = $xaction->getTransactionType(); $app_capability = null; if ($transaction_type == PhabricatorTransactions::TYPE_EDGE) { switch ($xaction->getMetadataValue('edge:type')) { case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST: $app_capability = ManiphestEditProjectsCapability::CAPABILITY; break; } } else { $app_capability = idx($app_capability_map, $transaction_type); } if ($app_capability) { $app = id(new PhabricatorApplicationQuery()) ->setViewer($this->getActor()) ->withClasses(array('PhabricatorManiphestApplication')) ->executeOne(); PhabricatorPolicyFilter::requireCapability( $this->getActor(), $app, $app_capability); } } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = parent::adjustObjectForPolicyChecks($object, $xactions); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_OWNER: $copy->setOwnerPHID($xaction->getNewValue()); break; default: continue; } } return $copy; } /** * Get priorities for moving a task to a new priority. */ public static function getEdgeSubpriority( $priority, $is_end) { $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPriorities(array($priority)) ->setLimit(1); if ($is_end) { $query->setOrderVector(array('-priority', '-subpriority', '-id')); } else { $query->setOrderVector(array('priority', 'subpriority', 'id')); } $result = $query->executeOne(); $step = (double)(2 << 32); if ($result) { $base = $result->getSubpriority(); if ($is_end) { $sub = ($base - $step); } else { $sub = ($base + $step); } } else { $sub = 0; } return array($priority, $sub); } /** * Get priorities for moving a task before or after another task. */ public static function getAdjacentSubpriority( ManiphestTask $dst, $is_after, $allow_recursion = true) { $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setOrder(ManiphestTaskQuery::ORDER_PRIORITY) ->withPriorities(array($dst->getPriority())) ->setLimit(1); if ($is_after) { $query->setAfterID($dst->getID()); } else { $query->setBeforeID($dst->getID()); } $adjacent = $query->executeOne(); $base = $dst->getSubpriority(); $step = (double)(2 << 32); // If we find an adjacent task, we average the two subpriorities and // return the result. if ($adjacent) { $epsilon = 0.01; // If the adjacent task has a subpriority that is identical or very // close to the task we're looking at, we're going to move it and all // tasks with the same subpriority a little farther down the subpriority // scale. if ($allow_recursion && (abs($adjacent->getSubpriority() - $base) < $epsilon)) { $conn_w = $adjacent->establishConnection('w'); $min = ($adjacent->getSubpriority() - ($epsilon)); $max = ($adjacent->getSubpriority() + ($epsilon)); // Get all of the tasks with the similar subpriorities to the adjacent // task, including the adjacent task itself. $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPriorities(array($adjacent->getPriority())) ->withSubpriorityBetween($min, $max); if (!$is_after) { $query->setOrderVector(array('-priority', '-subpriority', '-id')); } else { $query->setOrderVector(array('priority', 'subpriority', 'id')); } $shift_all = $query->execute(); $shift_last = last($shift_all); // Select the most extreme subpriority in the result set as the // base value. $shift_base = head($shift_all)->getSubpriority(); // Find the subpriority before or after the task at the end of the // block. list($shift_pri, $shift_sub) = self::getAdjacentSubpriority( $shift_last, $is_after, $allow_recursion = false); $delta = ($shift_sub - $shift_base); $count = count($shift_all); $shift = array(); $cursor = 1; foreach ($shift_all as $shift_task) { $shift_target = $shift_base + (($cursor / $count) * $delta); $cursor++; queryfx( $conn_w, 'UPDATE %T SET subpriority = %f WHERE id = %d', $adjacent->getTableName(), $shift_target, $shift_task->getID()); // If we're shifting the adjacent task, update it. if ($shift_task->getID() == $adjacent->getID()) { $adjacent->setSubpriority($shift_target); } // If we're shifting the original target task, update the base // subpriority. if ($shift_task->getID() == $dst->getID()) { $base = $shift_target; } } } $sub = ($adjacent->getSubpriority() + $base) / 2; } else { // Otherwise, we take a step away from the target's subpriority and // use that. if ($is_after) { $sub = ($base - $step); } else { $sub = ($base + $step); } } return array($dst->getPriority(), $sub); } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); switch ($type) { case ManiphestTransaction::TYPE_TITLE: $missing = $this->validateIsEmptyTextField( $object->getTitle(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('Task title is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } break; case ManiphestTransaction::TYPE_PARENT: - if ($xactions && !$this->getIsNewObject()) { - $error = new PhabricatorApplicationTransactionValidationError( + $with_effect = array(); + foreach ($xactions as $xaction) { + $task_phid = $xaction->getNewValue(); + if (!$task_phid) { + continue; + } + + $with_effect[] = $xaction; + + $task = id(new ManiphestTaskQuery()) + ->setViewer($this->getActor()) + ->withPHIDs(array($task_phid)) + ->executeOne(); + if (!$task) { + $errors[] = new PhabricatorApplicationTransactionValidationError( + $type, + pht('Invalid'), + pht( + 'Parent task identifier "%s" does not identify a visible '. + 'task.', + $task_phid), + $xaction); + } + } + + if ($with_effect && !$this->getIsNewObject()) { + $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can only select a parent task when creating a '. 'transaction for the first time.'), - last($xactions)); + last($with_effect)); + } + break; + case ManiphestTransaction::TYPE_COLUMN: + $with_effect = array(); + foreach ($xactions as $xaction) { + $column_phid = $xaction->getNewValue(); + if (!$column_phid) { + continue; + } + + $with_effect[] = $xaction; + + $column = $this->loadProjectColumn($column_phid); + if (!$column) { + $errors[] = new PhabricatorApplicationTransactionValidationError( + $type, + pht('Invalid'), + pht( + 'Column PHID "%s" does not identify a visible column.', + $column_phid), + $xaction); + } + } + + if ($with_effect && !$this->getIsNewObject()) { + $errors[] = new PhabricatorApplicationTransactionValidationError( + $type, + pht('Invalid'), + pht( + 'You can only put a task into an initial column during task '. + 'creation.'), + last($with_effect)); } break; } 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() == ManiphestTransaction::TYPE_OWNER) { $any_assign = true; break; } } $is_open = !$object->isClosed(); $new_status = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_STATUS: $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) { // Don't assign the actor if they aren't a real user. if ($actor_phid) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_OWNER) ->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); switch ($xaction->getTransactionType()) { + case ManiphestTransaction::TYPE_COLUMN: + $column_phid = $xaction->getNewValue(); + if (!$column_phid) { + break; + } + + // When a task is created into a column, we also generate a transaction + // to actually put it in that column. + $column = $this->loadProjectColumn($column_phid); + $results[] = id(new ManiphestTransaction()) + ->setTransactionType(ManiphestTransaction::TYPE_PROJECT_COLUMN) + ->setOldValue( + array( + 'projectPHID' => $column->getProjectPHID(), + 'columnPHIDs' => array(), + )) + ->setNewValue( + array( + 'projectPHID' => $column->getProjectPHID(), + 'columnPHIDs' => array($column->getPHID()), + )); + break; case ManiphestTransaction::TYPE_OWNER: // When a task is reassigned, move the old owner to the subscriber // list so they're still in the loop. $owner_phid = $object->getOwnerPHID(); if ($owner_phid) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setIgnoreOnNoEffect(true) ->setNewValue( array( '+' => array($owner_phid => $owner_phid), )); } break; } return $results; } + private function loadProjectColumn($column_phid) { + return id(new PhabricatorProjectColumnQuery()) + ->setViewer($this->getActor()) + ->withPHIDs(array($column_phid)) + ->executeOne(); + } + } diff --git a/src/applications/maniphest/storage/ManiphestTransaction.php b/src/applications/maniphest/storage/ManiphestTransaction.php index 7d184a9b59..f83b1faf10 100644 --- a/src/applications/maniphest/storage/ManiphestTransaction.php +++ b/src/applications/maniphest/storage/ManiphestTransaction.php @@ -1,926 +1,928 @@ getTransactionType()) { case self::TYPE_PROJECT_COLUMN: case self::TYPE_EDGE: case self::TYPE_UNBLOCK: return false; } return parent::shouldGenerateOldValue(); } public function getRemarkupBlocks() { $blocks = parent::getRemarkupBlocks(); switch ($this->getTransactionType()) { case self::TYPE_DESCRIPTION: $blocks[] = $this->getNewValue(); break; } return $blocks; } public function getRequiredHandlePHIDs() { $phids = parent::getRequiredHandlePHIDs(); $new = $this->getNewValue(); $old = $this->getOldValue(); switch ($this->getTransactionType()) { case self::TYPE_OWNER: if ($new) { $phids[] = $new; } if ($old) { $phids[] = $old; } break; case self::TYPE_PROJECT_COLUMN: $phids[] = $new['projectPHID']; $phids[] = head($new['columnPHIDs']); break; case self::TYPE_MERGED_INTO: $phids[] = $new; break; case self::TYPE_MERGED_FROM: $phids = array_merge($phids, $new); break; case self::TYPE_EDGE: $phids = array_mergev( array( $phids, array_keys(nonempty($old, array())), array_keys(nonempty($new, array())), )); break; case self::TYPE_ATTACH: $old = nonempty($old, array()); $new = nonempty($new, array()); $phids = array_mergev( array( $phids, array_keys(idx($new, 'FILE', array())), array_keys(idx($old, 'FILE', array())), )); break; case self::TYPE_UNBLOCK: foreach (array_keys($new) as $phid) { $phids[] = $phid; } break; case self::TYPE_STATUS: $commit_phid = $this->getMetadataValue('commitPHID'); if ($commit_phid) { $phids[] = $commit_phid; } break; } return $phids; } public function shouldHide() { switch ($this->getTransactionType()) { case PhabricatorTransactions::TYPE_EDGE: $commit_phid = $this->getMetadataValue('commitPHID'); $edge_type = $this->getMetadataValue('edge:type'); if ($edge_type == ManiphestTaskHasCommitEdgeType::EDGECONST) { if ($commit_phid) { return true; } } break; case self::TYPE_DESCRIPTION: case self::TYPE_PRIORITY: case self::TYPE_STATUS: if ($this->getOldValue() === null) { return true; } else { return false; } break; case self::TYPE_SUBPRIORITY: case self::TYPE_PARENT: + case self::TYPE_COLUMN: return true; case self::TYPE_PROJECT_COLUMN: $old_cols = idx($this->getOldValue(), 'columnPHIDs'); $new_cols = idx($this->getNewValue(), 'columnPHIDs'); $old_cols = array_values($old_cols); $new_cols = array_values($new_cols); sort($old_cols); sort($new_cols); return ($old_cols === $new_cols); } return parent::shouldHide(); } public function getActionStrength() { switch ($this->getTransactionType()) { case self::TYPE_TITLE: return 1.4; case self::TYPE_STATUS: return 1.3; case self::TYPE_OWNER: return 1.2; case self::TYPE_PRIORITY: return 1.1; } return parent::getActionStrength(); } public function getColor() { $old = $this->getOldValue(); $new = $this->getNewValue(); switch ($this->getTransactionType()) { case self::TYPE_OWNER: if ($this->getAuthorPHID() == $new) { return 'green'; } else if (!$new) { return 'black'; } else if (!$old) { return 'green'; } else { return 'green'; } case self::TYPE_STATUS: $color = ManiphestTaskStatus::getStatusColor($new); if ($color !== null) { return $color; } if (ManiphestTaskStatus::isOpenStatus($new)) { return 'green'; } else { return 'indigo'; } case self::TYPE_PRIORITY: if ($old == ManiphestTaskPriority::getDefaultPriority()) { return 'green'; } else if ($old > $new) { return 'grey'; } else { return 'yellow'; } case self::TYPE_MERGED_FROM: return 'orange'; case self::TYPE_MERGED_INTO: return 'indigo'; } return parent::getColor(); } public function getActionName() { $old = $this->getOldValue(); $new = $this->getNewValue(); switch ($this->getTransactionType()) { case self::TYPE_TITLE: if ($old === null) { return pht('Created'); } return pht('Retitled'); case self::TYPE_STATUS: $action = ManiphestTaskStatus::getStatusActionName($new); if ($action) { return $action; } $old_closed = ManiphestTaskStatus::isClosedStatus($old); $new_closed = ManiphestTaskStatus::isClosedStatus($new); if ($new_closed && !$old_closed) { return pht('Closed'); } else if (!$new_closed && $old_closed) { return pht('Reopened'); } else { return pht('Changed Status'); } case self::TYPE_DESCRIPTION: return pht('Edited'); case self::TYPE_OWNER: if ($this->getAuthorPHID() == $new) { return pht('Claimed'); } else if (!$new) { return pht('Up For Grabs'); } else if (!$old) { return pht('Assigned'); } else { return pht('Reassigned'); } case self::TYPE_PROJECT_COLUMN: return pht('Changed Project Column'); case self::TYPE_PRIORITY: if ($old == ManiphestTaskPriority::getDefaultPriority()) { return pht('Triaged'); } else if ($old > $new) { return pht('Lowered Priority'); } else { return pht('Raised Priority'); } case self::TYPE_EDGE: case self::TYPE_ATTACH: return pht('Attached'); case self::TYPE_UNBLOCK: $old_status = head($old); $new_status = head($new); $old_closed = ManiphestTaskStatus::isClosedStatus($old_status); $new_closed = ManiphestTaskStatus::isClosedStatus($new_status); if ($old_closed && !$new_closed) { return pht('Block'); } else if (!$old_closed && $new_closed) { return pht('Unblock'); } else { return pht('Blocker'); } case self::TYPE_MERGED_INTO: case self::TYPE_MERGED_FROM: return pht('Merged'); } return parent::getActionName(); } public function getIcon() { $old = $this->getOldValue(); $new = $this->getNewValue(); switch ($this->getTransactionType()) { case self::TYPE_OWNER: return 'fa-user'; case self::TYPE_TITLE: if ($old === null) { return 'fa-pencil'; } return 'fa-pencil'; case self::TYPE_STATUS: $action = ManiphestTaskStatus::getStatusIcon($new); if ($action !== null) { return $action; } if (ManiphestTaskStatus::isClosedStatus($new)) { return 'fa-check'; } else { return 'fa-pencil'; } case self::TYPE_DESCRIPTION: return 'fa-pencil'; case self::TYPE_PROJECT_COLUMN: return 'fa-columns'; case self::TYPE_MERGED_INTO: return 'fa-check'; case self::TYPE_MERGED_FROM: return 'fa-compress'; case self::TYPE_PRIORITY: if ($old == ManiphestTaskPriority::getDefaultPriority()) { return 'fa-arrow-right'; } else if ($old > $new) { return 'fa-arrow-down'; } else { return 'fa-arrow-up'; } case self::TYPE_EDGE: case self::TYPE_ATTACH: return 'fa-thumb-tack'; case self::TYPE_UNBLOCK: return 'fa-shield'; } return parent::getIcon(); } public function getTitle() { $author_phid = $this->getAuthorPHID(); $old = $this->getOldValue(); $new = $this->getNewValue(); switch ($this->getTransactionType()) { case self::TYPE_TITLE: if ($old === null) { return pht( '%s created this task.', $this->renderHandleLink($author_phid)); } return pht( '%s changed the title from "%s" to "%s".', $this->renderHandleLink($author_phid), $old, $new); case self::TYPE_DESCRIPTION: return pht( '%s edited the task description.', $this->renderHandleLink($author_phid)); case self::TYPE_STATUS: $old_closed = ManiphestTaskStatus::isClosedStatus($old); $new_closed = ManiphestTaskStatus::isClosedStatus($new); $old_name = ManiphestTaskStatus::getTaskStatusName($old); $new_name = ManiphestTaskStatus::getTaskStatusName($new); $commit_phid = $this->getMetadataValue('commitPHID'); if ($new_closed && !$old_closed) { if ($new == ManiphestTaskStatus::getDuplicateStatus()) { if ($commit_phid) { return pht( '%s closed this task as a duplicate by committing %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($commit_phid)); } else { return pht( '%s closed this task as a duplicate.', $this->renderHandleLink($author_phid)); } } else { if ($commit_phid) { return pht( '%s closed this task as "%s" by committing %s.', $this->renderHandleLink($author_phid), $new_name, $this->renderHandleLink($commit_phid)); } else { return pht( '%s closed this task as "%s".', $this->renderHandleLink($author_phid), $new_name); } } } else if (!$new_closed && $old_closed) { if ($commit_phid) { return pht( '%s reopened this task as "%s" by committing %s.', $this->renderHandleLink($author_phid), $new_name, $this->renderHandleLink($commit_phid)); } else { return pht( '%s reopened this task as "%s".', $this->renderHandleLink($author_phid), $new_name); } } else { if ($commit_phid) { return pht( '%s changed the task status from "%s" to "%s" by committing %s.', $this->renderHandleLink($author_phid), $old_name, $new_name, $this->renderHandleLink($commit_phid)); } else { return pht( '%s changed the task status from "%s" to "%s".', $this->renderHandleLink($author_phid), $old_name, $new_name); } } case self::TYPE_UNBLOCK: $blocker_phid = key($new); $old_status = head($old); $new_status = head($new); $old_closed = ManiphestTaskStatus::isClosedStatus($old_status); $new_closed = ManiphestTaskStatus::isClosedStatus($new_status); $old_name = ManiphestTaskStatus::getTaskStatusName($old_status); $new_name = ManiphestTaskStatus::getTaskStatusName($new_status); if ($old_closed && !$new_closed) { return pht( '%s reopened blocking task %s as "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($blocker_phid), $new_name); } else if (!$old_closed && $new_closed) { return pht( '%s closed blocking task %s as "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($blocker_phid), $new_name); } else { return pht( '%s changed the status of blocking task %s from "%s" to "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($blocker_phid), $old_name, $new_name); } case self::TYPE_OWNER: if ($author_phid == $new) { return pht( '%s claimed this task.', $this->renderHandleLink($author_phid)); } else if (!$new) { return pht( '%s placed this task up for grabs.', $this->renderHandleLink($author_phid)); } else if (!$old) { return pht( '%s assigned this task to %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($new)); } else { return pht( '%s reassigned this task from %s to %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($old), $this->renderHandleLink($new)); } case self::TYPE_PRIORITY: $old_name = ManiphestTaskPriority::getTaskPriorityName($old); $new_name = ManiphestTaskPriority::getTaskPriorityName($new); if ($old == ManiphestTaskPriority::getDefaultPriority()) { return pht( '%s triaged this task as "%s" priority.', $this->renderHandleLink($author_phid), $new_name); } else if ($old > $new) { return pht( '%s lowered the priority of this task from "%s" to "%s".', $this->renderHandleLink($author_phid), $old_name, $new_name); } else { return pht( '%s raised the priority of this task from "%s" to "%s".', $this->renderHandleLink($author_phid), $old_name, $new_name); } case self::TYPE_ATTACH: $old = nonempty($old, array()); $new = nonempty($new, array()); $new = array_keys(idx($new, 'FILE', array())); $old = array_keys(idx($old, 'FILE', array())); $added = array_diff($new, $old); $removed = array_diff($old, $new); if ($added && !$removed) { return pht( '%s attached %s file(s): %s.', $this->renderHandleLink($author_phid), phutil_count($added), $this->renderHandleList($added)); } else if ($removed && !$added) { return pht( '%s detached %s file(s): %s.', $this->renderHandleLink($author_phid), phutil_count($removed), $this->renderHandleList($removed)); } else { return pht( '%s changed file(s), attached %s: %s; detached %s: %s.', $this->renderHandleLink($author_phid), phutil_count($added), $this->renderHandleList($added), phutil_count($removed), $this->renderHandleList($removed)); } case self::TYPE_PROJECT_COLUMN: $project_phid = $new['projectPHID']; $column_phid = head($new['columnPHIDs']); return pht( '%s moved this task to %s on the %s workboard.', $this->renderHandleLink($author_phid), $this->renderHandleLink($column_phid), $this->renderHandleLink($project_phid)); break; case self::TYPE_MERGED_INTO: return pht( '%s closed this task as a duplicate of %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($new)); break; case self::TYPE_MERGED_FROM: return pht( '%s merged %s task(s): %s.', $this->renderHandleLink($author_phid), phutil_count($new), $this->renderHandleList($new)); break; } return parent::getTitle(); } public function getTitleForFeed() { $author_phid = $this->getAuthorPHID(); $object_phid = $this->getObjectPHID(); $old = $this->getOldValue(); $new = $this->getNewValue(); switch ($this->getTransactionType()) { case self::TYPE_TITLE: if ($old === null) { return pht( '%s created %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); } return pht( '%s renamed %s from "%s" to "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $old, $new); case self::TYPE_DESCRIPTION: return pht( '%s edited the description of %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); case self::TYPE_STATUS: $old_closed = ManiphestTaskStatus::isClosedStatus($old); $new_closed = ManiphestTaskStatus::isClosedStatus($new); $old_name = ManiphestTaskStatus::getTaskStatusName($old); $new_name = ManiphestTaskStatus::getTaskStatusName($new); $commit_phid = $this->getMetadataValue('commitPHID'); if ($new_closed && !$old_closed) { if ($new == ManiphestTaskStatus::getDuplicateStatus()) { if ($commit_phid) { return pht( '%s closed %s as a duplicate by committing %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $this->renderHandleLink($commit_phid)); } else { return pht( '%s closed %s as a duplicate.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); } } else { if ($commit_phid) { return pht( '%s closed %s as "%s" by committing %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $new_name, $this->renderHandleLink($commit_phid)); } else { return pht( '%s closed %s as "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $new_name); } } } else if (!$new_closed && $old_closed) { if ($commit_phid) { return pht( '%s reopened %s as "%s" by committing %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $new_name, $this->renderHandleLink($commit_phid)); } else { return pht( '%s reopened %s as "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $new_name); } } else { if ($commit_phid) { return pht( '%s changed the status of %s from "%s" to "%s" by committing %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $old_name, $new_name, $this->renderHandleLink($commit_phid)); } else { return pht( '%s changed the status of %s from "%s" to "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $old_name, $new_name); } } case self::TYPE_UNBLOCK: $blocker_phid = key($new); $old_status = head($old); $new_status = head($new); $old_closed = ManiphestTaskStatus::isClosedStatus($old_status); $new_closed = ManiphestTaskStatus::isClosedStatus($new_status); $old_name = ManiphestTaskStatus::getTaskStatusName($old_status); $new_name = ManiphestTaskStatus::getTaskStatusName($new_status); if ($old_closed && !$new_closed) { return pht( '%s reopened %s, a task blocking %s, as "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($blocker_phid), $this->renderHandleLink($object_phid), $new_name); } else if (!$old_closed && $new_closed) { return pht( '%s closed %s, a task blocking %s, as "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($blocker_phid), $this->renderHandleLink($object_phid), $new_name); } else { return pht( '%s changed the status of %s, a task blocking %s, '. 'from "%s" to "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($blocker_phid), $this->renderHandleLink($object_phid), $old_name, $new_name); } case self::TYPE_OWNER: if ($author_phid == $new) { return pht( '%s claimed %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); } else if (!$new) { return pht( '%s placed %s up for grabs.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); } else if (!$old) { return pht( '%s assigned %s to %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $this->renderHandleLink($new)); } else { return pht( '%s reassigned %s from %s to %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $this->renderHandleLink($old), $this->renderHandleLink($new)); } case self::TYPE_PRIORITY: $old_name = ManiphestTaskPriority::getTaskPriorityName($old); $new_name = ManiphestTaskPriority::getTaskPriorityName($new); if ($old == ManiphestTaskPriority::getDefaultPriority()) { return pht( '%s triaged %s as "%s" priority.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $new_name); } else if ($old > $new) { return pht( '%s lowered the priority of %s from "%s" to "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $old_name, $new_name); } else { return pht( '%s raised the priority of %s from "%s" to "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $old_name, $new_name); } case self::TYPE_ATTACH: $old = nonempty($old, array()); $new = nonempty($new, array()); $new = array_keys(idx($new, 'FILE', array())); $old = array_keys(idx($old, 'FILE', array())); $added = array_diff($new, $old); $removed = array_diff($old, $new); if ($added && !$removed) { return pht( '%s attached %d file(s) of %s: %s', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), count($added), $this->renderHandleList($added)); } else if ($removed && !$added) { return pht( '%s detached %d file(s) of %s: %s', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), count($removed), $this->renderHandleList($removed)); } else { return pht( '%s changed file(s) for %s, attached %d: %s; detached %d: %s', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), count($added), $this->renderHandleList($added), count($removed), $this->renderHandleList($removed)); } case self::TYPE_PROJECT_COLUMN: $project_phid = $new['projectPHID']; $column_phid = head($new['columnPHIDs']); return pht( '%s moved %s to %s on the %s workboard.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $this->renderHandleLink($column_phid), $this->renderHandleLink($project_phid)); case self::TYPE_MERGED_INTO: return pht( '%s merged task %s into %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid), $this->renderHandleLink($new)); case self::TYPE_MERGED_FROM: return pht( '%s merged %s task(s) %s into %s.', $this->renderHandleLink($author_phid), phutil_count($new), $this->renderHandleList($new), $this->renderHandleLink($object_phid)); } return parent::getTitleForFeed(); } public function hasChangeDetails() { switch ($this->getTransactionType()) { case self::TYPE_DESCRIPTION: return true; } return parent::hasChangeDetails(); } public function renderChangeDetails(PhabricatorUser $viewer) { return $this->renderTextCorpusChangeDetails( $viewer, $this->getOldValue(), $this->getNewValue()); } public function getMailTags() { $tags = array(); switch ($this->getTransactionType()) { case self::TYPE_MERGED_INTO: case self::TYPE_STATUS: $tags[] = self::MAILTAG_STATUS; break; case self::TYPE_OWNER: $tags[] = self::MAILTAG_OWNER; break; case PhabricatorTransactions::TYPE_SUBSCRIBERS: $tags[] = self::MAILTAG_CC; break; case PhabricatorTransactions::TYPE_EDGE: switch ($this->getMetadataValue('edge:type')) { case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST: $tags[] = self::MAILTAG_PROJECTS; break; default: $tags[] = self::MAILTAG_OTHER; break; } break; case self::TYPE_PRIORITY: $tags[] = self::MAILTAG_PRIORITY; break; case self::TYPE_UNBLOCK: $tags[] = self::MAILTAG_UNBLOCK; break; case self::TYPE_PROJECT_COLUMN: $tags[] = self::MAILTAG_COLUMN; break; case PhabricatorTransactions::TYPE_COMMENT: $tags[] = self::MAILTAG_COMMENT; break; default: $tags[] = self::MAILTAG_OTHER; break; } return $tags; } public function getNoEffectDescription() { switch ($this->getTransactionType()) { case self::TYPE_STATUS: return pht('The task already has the selected status.'); case self::TYPE_OWNER: return pht('The task already has the selected owner.'); case self::TYPE_PRIORITY: return pht('The task already has the selected priority.'); } return parent::getNoEffectDescription(); } } diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 2d47031942..6794dc0944 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -1,771 +1,789 @@ getUser(); $id = $request->getURIData('id'); $show_hidden = $request->getBool('hidden'); $this->showHidden = $show_hidden; $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->needImages(true); $id = $request->getURIData('id'); $slug = $request->getURIData('slug'); if ($slug) { $project->withSlugs(array($slug)); } else { $project->withIDs(array($id)); } $project = $project->executeOne(); if (!$project) { return new Aphront404Response(); } $this->setProject($project); $this->id = $project->getID(); $sort_key = $request->getStr('order'); switch ($sort_key) { case PhabricatorProjectColumn::ORDER_NATURAL: case PhabricatorProjectColumn::ORDER_PRIORITY: break; default: $sort_key = PhabricatorProjectColumn::DEFAULT_ORDER; break; } $this->sortKey = $sort_key; $column_query = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array($project->getPHID())); if (!$show_hidden) { $column_query->withStatuses( array(PhabricatorProjectColumn::STATUS_ACTIVE)); } $columns = $column_query->execute(); $columns = mpull($columns, null, 'getSequence'); // TODO: Expand the checks here if we add the ability // to hide the Backlog column if (!$columns) { $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); if (!$can_edit) { return $this->noAccessDialog($project); } switch ($request->getStr('initialize-type')) { case 'backlog-only': $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $column = PhabricatorProjectColumn::initializeNewColumn($viewer) ->setSequence(0) ->setProperty('isDefault', true) ->setProjectPHID($project->getPHID()) ->save(); $column->attachProject($project); $columns[0] = $column; unset($unguarded); break; case 'import': return id(new AphrontRedirectResponse()) ->setURI( $this->getApplicationURI('board/'.$project->getID().'/import/')); break; default: return $this->initializeWorkboardDialog($project); break; } } ksort($columns); $board_uri = $this->getApplicationURI('board/'.$project->getID().'/'); $engine = id(new ManiphestTaskSearchEngine()) ->setViewer($viewer) ->setBaseURI($board_uri) ->setIsBoardView(true); if ($request->isFormPost()) { $saved = $engine->buildSavedQueryFromRequest($request); $engine->saveQuery($saved); $filter_form = id(new AphrontFormView()) ->setUser($viewer); $engine->buildSearchForm($filter_form, $saved); if ($engine->getErrors()) { return $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FULL) ->setTitle(pht('Advanced Filter')) ->appendChild($filter_form->buildLayoutView()) ->setErrors($engine->getErrors()) ->setSubmitURI($board_uri) ->addSubmitButton(pht('Apply Filter')) ->addCancelButton($board_uri); } return id(new AphrontRedirectResponse())->setURI( $this->getURIWithState( $engine->getQueryResultsPageURI($saved->getQueryKey()))); } $query_key = $request->getURIData('queryKey'); if (!$query_key) { $query_key = 'open'; } $this->queryKey = $query_key; $custom_query = null; if ($engine->isBuiltinQuery($query_key)) { $saved = $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); $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 = $engine->buildQueryFromSavedQuery($saved); $tasks = $task_query ->withEdgeLogicPHIDs( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, PhabricatorQueryConstraint::OPERATOR_AND, array($project->getPHID())) ->setOrder(ManiphestTaskQuery::ORDER_PRIORITY) ->setViewer($viewer) ->execute(); $tasks = mpull($tasks, null, 'getPHID'); if ($tasks) { $positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($viewer) ->withObjectPHIDs(mpull($tasks, 'getPHID')) ->withColumns($columns) ->execute(); $positions = mpull($positions, null, 'getObjectPHID'); } else { $positions = array(); } $task_map = array(); foreach ($tasks as $task) { $task_phid = $task->getPHID(); if (empty($positions[$task_phid])) { // This shouldn't normally be possible because we create positions on // demand, but we might have raced as an object was removed from the // board. Just drop the task if we don't have a position for it. continue; } $position = $positions[$task_phid]; $task_map[$position->getColumnPHID()][] = $task_phid; } // If we're showing the board in "natural" order, sort columns by their // column positions. if ($this->sortKey == PhabricatorProjectColumn::ORDER_NATURAL) { foreach ($task_map as $column_phid => $task_phids) { $order = array(); foreach ($task_phids as $task_phid) { if (isset($positions[$task_phid])) { $order[$task_phid] = $positions[$task_phid]->getOrderingKey(); } else { $order[$task_phid] = 0; } } asort($order); $task_map[$column_phid] = array_keys($order); } } $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 = idx($task_map, $batch_column->getPHID(), array()); foreach ($batch_task_phids as $key => $batch_task_phid) { if (empty($task_can_edit_map[$batch_task_phid])) { unset($batch_task_phids[$key]); } } $batch_tasks = array_select_keys($tasks, $batch_task_phids); } else { $batch_tasks = $task_can_edit_map; } if (!$batch_tasks) { $cancel_uri = $this->getURIWithState($board_uri); return $this->newDialog() ->setTitle(pht('No Editable Tasks')) ->appendParagraph( pht( 'The selected column contains no visible tasks which you '. 'have permission to edit.')) ->addCancelButton($board_uri); } $batch_ids = mpull($batch_tasks, 'getID'); $batch_ids = implode(',', $batch_ids); $batch_uri = new PhutilURI('/maniphest/batch/'); $batch_uri->setQueryParam('board', $this->id); $batch_uri->setQueryParam('batch', $batch_ids); return id(new AphrontRedirectResponse()) ->setURI($batch_uri); } $board_id = celerity_generate_unique_node_id(); $board = id(new PHUIWorkboardView()) ->setUser($viewer) ->setID($board_id); $behavior_config = array( 'boardID' => $board_id, 'projectPHID' => $project->getPHID(), 'moveURI' => $this->getApplicationURI('move/'.$project->getID().'/'), - 'createURI' => '/maniphest/task/create/', + 'createURI' => $this->getCreateURI(), 'order' => $this->sortKey, ); $this->initBehavior( 'project-boards', $behavior_config); $this->handles = ManiphestTaskListView::loadTaskHandles($viewer, $tasks); foreach ($columns as $column) { $task_phids = idx($task_map, $column->getPHID(), array()); $column_tasks = array_select_keys($tasks, $task_phids); $panel = id(new PHUIWorkpanelView()) ->setHeader($column->getDisplayName()) ->setSubHeader($column->getDisplayType()) ->addSigil('workpanel'); $header_icon = $column->getHeaderIcon(); if ($header_icon) { $panel->setHeaderIcon($header_icon); } if ($column->isHidden()) { $panel->addClass('project-panel-hidden'); } $column_menu = $this->buildColumnMenu($project, $column); $panel->addHeaderAction($column_menu); $tag_id = celerity_generate_unique_node_id(); $tag_content_id = celerity_generate_unique_node_id(); $count_tag = id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setShade(PHUITagView::COLOR_BLUE) ->setID($tag_id) ->setName(phutil_tag('span', array('id' => $tag_content_id), '-')) ->setStyle('display: none'); $panel->setHeaderTag($count_tag); $cards = id(new PHUIObjectItemListView()) ->setUser($viewer) ->setFlush(true) ->setAllowEmptyList(true) ->addSigil('project-column') ->setMetadata( array( 'columnPHID' => $column->getPHID(), 'countTagID' => $tag_id, 'countTagContentID' => $tag_content_id, 'pointLimit' => $column->getPointLimit(), )); foreach ($column_tasks as $task) { $owner = null; if ($task->getOwnerPHID()) { $owner = $this->handles[$task->getOwnerPHID()]; } $can_edit = idx($task_can_edit_map, $task->getPHID(), false); $cards->addItem(id(new ProjectBoardTaskCard()) ->setViewer($viewer) ->setTask($task) ->setOwner($owner) ->setCanEdit($can_edit) ->getItem()); } $panel->setCards($cards); $board->addPanel($panel); } $sort_menu = $this->buildSortMenu( $viewer, $sort_key); $filter_menu = $this->buildFilterMenu( $viewer, $custom_query, $engine, $query_key); $manage_menu = $this->buildManageMenu($project, $show_hidden); $header_link = phutil_tag( 'a', array( 'href' => $this->getApplicationURI('profile/'.$project->getID().'/'), ), $project->getName()); $header = id(new PHUIHeaderView()) ->setHeader($header_link) ->setUser($viewer) ->setNoBackground(true) ->addActionLink($sort_menu) ->addActionLink($filter_menu) ->addActionLink($manage_menu) ->setPolicyObject($project); $header_box = id(new PHUIBoxView()) ->appendChild($header) ->addClass('project-board-header'); $board_box = id(new PHUIBoxView()) ->appendChild($board) ->addClass('project-board-wrapper'); $nav = $this->buildIconNavView($project); return $this->newPage() ->setTitle(pht('%s Board', $project->getName())) ->setPageObjectPHIDs(array($project->getPHID())) ->setShowFooter(false) ->setNavigation($nav) ->addQuicksandConfig( array( 'boardConfig' => $behavior_config, )) ->appendChild( array( $header_box, $board_box, )); } private function buildSortMenu( PhabricatorUser $viewer, $sort_key) { $sort_icon = id(new PHUIIconView()) ->setIconFont('fa-sort-amount-asc bluegrey'); $named = array( PhabricatorProjectColumn::ORDER_NATURAL => pht('Natural'), PhabricatorProjectColumn::ORDER_PRIORITY => pht('Sort by Priority'), ); $base_uri = $this->getURIWithState(); $items = array(); foreach ($named as $key => $name) { $is_selected = ($key == $sort_key); if ($is_selected) { $active_order = $name; } $item = id(new PhabricatorActionView()) ->setIcon('fa-sort-amount-asc') ->setSelected($is_selected) ->setName($name); $uri = $base_uri->alter('order', $key); $item->setHref($uri); $items[] = $item; } $sort_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($items as $item) { $sort_menu->addAction($item); } $sort_button = id(new PHUIButtonView()) ->setText(pht('Sort: %s', $active_order)) ->setIcon($sort_icon) ->setTag('a') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $sort_menu), )); return $sort_button; } private function buildFilterMenu( PhabricatorUser $viewer, $custom_query, PhabricatorApplicationSearchEngine $engine, $query_key) { $filter_icon = id(new PHUIIconView()) ->setIconFont('fa-search-plus bluegrey'); $named = array( 'open' => pht('Open Tasks'), 'all' => pht('All Tasks'), ); if ($viewer->isLoggedIn()) { $named['assigned'] = pht('Assigned to Me'); } if ($custom_query) { $named[$custom_query->getQueryKey()] = pht('Custom Filter'); } $items = array(); foreach ($named as $key => $name) { $is_selected = ($key == $query_key); if ($is_selected) { $active_filter = $name; } $is_custom = false; if ($custom_query) { $is_custom = ($key == $custom_query->getQueryKey()); } $item = id(new PhabricatorActionView()) ->setIcon('fa-search') ->setSelected($is_selected) ->setName($name); if ($is_custom) { $uri = $this->getApplicationURI( 'board/'.$this->id.'/filter/query/'.$key.'/'); $item->setWorkflow(true); } else { $uri = $engine->getQueryResultsPageURI($key); } $uri = $this->getURIWithState($uri); $item->setHref($uri); $items[] = $item; } $items[] = id(new PhabricatorActionView()) ->setIcon('fa-cog') ->setHref($this->getApplicationURI('board/'.$this->id.'/filter/')) ->setWorkflow(true) ->setName(pht('Advanced Filter...')); $filter_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($items as $item) { $filter_menu->addAction($item); } $filter_button = id(new PHUIButtonView()) ->setText(pht('Filter: %s', $active_filter)) ->setIcon($filter_icon) ->setTag('a') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $filter_menu), )); return $filter_button; } private function buildManageMenu( PhabricatorProject $project, $show_hidden) { $request = $this->getRequest(); $viewer = $request->getUser(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $manage_icon = id(new PHUIIconView()) ->setIconFont('fa-cog bluegrey'); $manage_items = array(); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-plus') ->setName(pht('Add Column')) ->setHref($this->getApplicationURI('board/'.$this->id.'/edit/')) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-exchange') ->setName(pht('Reorder Columns')) ->setHref($this->getApplicationURI('board/'.$this->id.'/reorder/')) ->setDisabled(!$can_edit) ->setWorkflow(true); if ($show_hidden) { $hidden_uri = $this->getURIWithState() ->setQueryParam('hidden', null); $hidden_icon = 'fa-eye-slash'; $hidden_text = pht('Hide Hidden Columns'); } else { $hidden_uri = $this->getURIWithState() ->setQueryParam('hidden', 'true'); $hidden_icon = 'fa-eye'; $hidden_text = pht('Show Hidden Columns'); } $manage_items[] = id(new PhabricatorActionView()) ->setIcon($hidden_icon) ->setName($hidden_text) ->setHref($hidden_uri); $batch_edit_uri = $request->getRequestURI(); $batch_edit_uri->setQueryParam('batch', self::BATCH_EDIT_ALL); $can_batch_edit = PhabricatorPolicyFilter::hasCapability( $viewer, PhabricatorApplication::getByClass('PhabricatorManiphestApplication'), ManiphestBulkEditCapability::CAPABILITY); $manage_items[] = id(new PhabricatorActionView()) ->setIcon('fa-list-ul') ->setName(pht('Batch Edit Visible Tasks...')) ->setHref($batch_edit_uri) ->setDisabled(!$can_batch_edit); $manage_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($manage_items as $item) { $manage_menu->addAction($item); } $manage_button = id(new PHUIButtonView()) ->setText(pht('Manage Board')) ->setIcon($manage_icon) ->setTag('a') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $manage_menu), )); return $manage_button; } private function buildColumnMenu( PhabricatorProject $project, PhabricatorProjectColumn $column) { $request = $this->getRequest(); $viewer = $request->getUser(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $project, PhabricatorPolicyCapability::CAN_EDIT); $column_items = array(); $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-plus') ->setName(pht('Create Task...')) - ->setHref('/maniphest/task/create/') + ->setHref($this->getCreateURI()) ->addSigil('column-add-task') ->setMetadata( array( 'columnPHID' => $column->getPHID(), )); $batch_edit_uri = $request->getRequestURI(); $batch_edit_uri->setQueryParam('batch', $column->getID()); $can_batch_edit = PhabricatorPolicyFilter::hasCapability( $viewer, PhabricatorApplication::getByClass('PhabricatorManiphestApplication'), ManiphestBulkEditCapability::CAPABILITY); $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-list-ul') ->setName(pht('Batch Edit Tasks...')) ->setHref($batch_edit_uri) ->setDisabled(!$can_batch_edit); $detail_uri = $this->getApplicationURI( 'board/'.$this->id.'/column/'.$column->getID().'/'); $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-columns') ->setName(pht('Column Details')) ->setHref($detail_uri); $can_hide = ($can_edit && !$column->isDefaultColumn()); $hide_uri = 'board/'.$this->id.'/hide/'.$column->getID().'/'; $hide_uri = $this->getApplicationURI($hide_uri); $hide_uri = $this->getURIWithState($hide_uri); if (!$column->isHidden()) { $column_items[] = id(new PhabricatorActionView()) ->setName(pht('Hide Column')) ->setIcon('fa-eye-slash') ->setHref($hide_uri) ->setDisabled(!$can_hide) ->setWorkflow(true); } else { $column_items[] = id(new PhabricatorActionView()) ->setName(pht('Show Column')) ->setIcon('fa-eye') ->setHref($hide_uri) ->setDisabled(!$can_hide) ->setWorkflow(true); } $column_menu = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($column_items as $item) { $column_menu->addAction($item); } $column_button = id(new PHUIIconView()) ->setIconFont('fa-caret-down') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( array( 'items' => hsprintf('%s', $column_menu), )); return $column_button; } private function initializeWorkboardDialog(PhabricatorProject $project) { $instructions = pht('This workboard has not been setup yet.'); $new_selector = id(new AphrontFormRadioButtonControl()) ->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.')); $dialog = id(new AphrontDialogView()) ->setUser($this->getRequest()->getUser()) ->setTitle(pht('New Workboard')) ->addSubmitButton('Continue') ->addCancelButton($this->getApplicationURI('view/'.$project->getID().'/')) ->appendParagraph($instructions) ->appendChild($new_selector); return id(new AphrontDialogResponse()) ->setDialog($dialog); } private function noAccessDialog(PhabricatorProject $project) { $instructions = pht('This workboard has not been setup yet.'); $dialog = id(new AphrontDialogView()) ->setUser($this->getRequest()->getUser()) ->setTitle(pht('No Workboard')) ->addCancelButton($this->getApplicationURI('view/'.$project->getID().'/')) ->appendParagraph($instructions); return id(new AphrontDialogResponse()) ->setDialog($dialog); } /** * Add current state parameters (like order and the visibility of hidden * columns) to a URI. * * This allows actions which toggle or adjust one piece of state to keep * the rest of the board state persistent. If no URI is provided, this method * starts with the request URI. * * @param string|null URI to add state parameters to. * @return PhutilURI URI with state parameters. */ private function getURIWithState($base = null) { if ($base === null) { $base = $this->getRequest()->getRequestURI(); } $base = new PhutilURI($base); if ($this->sortKey != PhabricatorProjectColumn::DEFAULT_ORDER) { $base->setQueryParam('order', $this->sortKey); } else { $base->setQueryParam('order', null); } $base->setQueryParam('hidden', $this->showHidden ? 'true' : null); return $base; } + private function getCreateURI() { + $viewer = $this->getViewer(); + + // TODO: This should be cleaned up, but maybe we're going to make options + // for each column or board? + $edit_config = id(new ManiphestEditEngine()) + ->setViewer($viewer) + ->loadDefaultEditConfiguration(); + if ($edit_config) { + $form_key = $edit_config->getIdentifier(); + $create_uri = "/maniphest/editpro/form/{$form_key}/"; + } else { + $create_uri = '/maniphest/editpro/'; + } + + return $create_uri; + } + } diff --git a/src/applications/search/engine/PhabricatorJumpNavHandler.php b/src/applications/search/engine/PhabricatorJumpNavHandler.php index 4a03ec9a8a..225e20553e 100644 --- a/src/applications/search/engine/PhabricatorJumpNavHandler.php +++ b/src/applications/search/engine/PhabricatorJumpNavHandler.php @@ -1,122 +1,122 @@ 'uri:/audit/', '/^f$/i' => 'uri:/feed/', '/^d$/i' => 'uri:/differential/', '/^r$/i' => 'uri:/diffusion/', '/^t$/i' => 'uri:/maniphest/', '/^p$/i' => 'uri:/project/', '/^u$/i' => 'uri:/people/', '/^p\s+(.+)$/i' => 'project', '/^u\s+(\S+)$/i' => 'user', '/^task:\s*(.+)/i' => 'create-task', '/^(?:s)\s+(\S+)/i' => 'find-symbol', '/^r\s+(.+)$/i' => 'find-repository', ); foreach ($patterns as $pattern => $effect) { $matches = null; if (preg_match($pattern, $jump, $matches)) { if (!strncmp($effect, 'uri:', 4)) { return id(new AphrontRedirectResponse()) ->setURI(substr($effect, 4)); } else { switch ($effect) { case 'user': return id(new AphrontRedirectResponse()) ->setURI('/p/'.$matches[1].'/'); case 'project': $project = self::findCloselyNamedProject($matches[1]); if ($project) { return id(new AphrontRedirectResponse()) ->setURI('/project/view/'.$project->getID().'/'); } else { $jump = $matches[1]; } break; case 'find-symbol': $context = ''; $symbol = $matches[1]; $parts = array(); if (preg_match('/(.*)(?:\\.|::|->)(.*)/', $symbol, $parts)) { $context = '&context='.phutil_escape_uri($parts[1]); $symbol = $parts[2]; } return id(new AphrontRedirectResponse()) ->setURI("/diffusion/symbol/$symbol/?jump=true$context"); case 'find-repository': $name = $matches[1]; $repositories = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withNameContains($name) ->execute(); if (count($repositories) == 1) { // Just one match, jump to repository. $uri = '/diffusion/'.head($repositories)->getCallsign().'/'; } else { // More than one match, jump to search. $uri = urisprintf('/diffusion/?order=name&name=%s', $name); } return id(new AphrontRedirectResponse())->setURI($uri); case 'create-task': return id(new AphrontRedirectResponse()) - ->setURI('/maniphest/task/create/?title=' + ->setURI('/maniphest/editpro/?title=' .phutil_escape_uri($matches[1])); default: throw new Exception(pht("Unknown jump effect '%s'!", $effect)); } } } } // If none of the patterns matched, look for an object by name. $objects = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withNames(array($jump)) ->execute(); if (count($objects) == 1) { $handle = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs(mpull($objects, 'getPHID')) ->executeOne(); return id(new AphrontRedirectResponse())->setURI($handle->getURI()); } return null; } private static function findCloselyNamedProject($name) { $project = id(new PhabricatorProject())->loadOneWhere( 'name = %s', $name); if ($project) { return $project; } else { // no exact match, try a fuzzy match $projects = id(new PhabricatorProject())->loadAllWhere( 'name LIKE %~', $name); if ($projects) { $min_name_length = PHP_INT_MAX; $best_project = null; foreach ($projects as $project) { $name_length = strlen($project->getName()); if ($name_length <= $min_name_length) { $min_name_length = $name_length; $best_project = $project; } } return $best_project; } else { return null; } } } } diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php index 969ed8d264..fc208a750f 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngine.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php @@ -1,1720 +1,1743 @@ viewer = $viewer; return $this; } final public function getViewer() { return $this->viewer; } final public function setController(PhabricatorController $controller) { $this->controller = $controller; $this->setViewer($controller->getViewer()); return $this; } final public function getController() { return $this->controller; } final public function getEngineKey() { return $this->getPhobjectClassConstant('ENGINECONST', 64); } final public function getApplication() { $app_class = $this->getEngineApplicationClass(); return PhabricatorApplication::getByClass($app_class); } final public function addContextParameter($key) { $this->contextParameters[] = $key; return $this; } /* -( Managing Fields )---------------------------------------------------- */ abstract public function getEngineApplicationClass(); abstract protected function buildCustomEditFields($object); public function getFieldsForConfig( PhabricatorEditEngineConfiguration $config) { $object = $this->newEditableObject(); $this->editEngineConfiguration = $config; // This is mostly making sure that we fill in default values. $this->setIsCreate(true); return $this->buildEditFields($object); } final protected function buildEditFields($object) { $viewer = $this->getViewer(); $fields = $this->buildCustomEditFields($object); $extensions = PhabricatorEditEngineExtension::getAllEnabledExtensions(); foreach ($extensions as $extension) { $extension->setViewer($viewer); if (!$extension->supportsObject($this, $object)) { continue; } $extension_fields = $extension->buildCustomEditFields($this, $object); // TODO: Validate this in more detail with a more tailored error. assert_instances_of($extension_fields, 'PhabricatorEditField'); foreach ($extension_fields as $field) { $fields[] = $field; } } $config = $this->getEditEngineConfiguration(); $fields = $config->applyConfigurationToFields($this, $object, $fields); foreach ($fields as $field) { $field ->setViewer($viewer) ->setObject($object); } return $fields; } /* -( Display Text )------------------------------------------------------- */ /** * @task text */ abstract public function getEngineName(); /** * @task text */ abstract protected function getObjectCreateTitleText($object); /** * @task text */ protected function getFormHeaderText($object) { $config = $this->getEditEngineConfiguration(); return $config->getName(); } /** * @task text */ abstract protected function getObjectEditTitleText($object); /** * @task text */ abstract protected function getObjectCreateShortText(); /** * @task text */ abstract protected function getObjectEditShortText($object); /** * @task text */ protected function getObjectCreateButtonText($object) { return $this->getObjectCreateTitleText($object); } /** * @task text */ protected function getObjectEditButtonText($object) { return pht('Save Changes'); } /** * @task text */ protected function getCommentViewHeaderText($object) { return pht('Add Comment'); } /** * @task text */ protected function getCommentViewButtonText($object) { return pht('Add Comment'); } /** * @task text */ protected function getQuickCreateMenuHeaderText() { return $this->getObjectCreateShortText(); } /* -( Edit Engine Configuration )------------------------------------------ */ protected function supportsEditEngineConfiguration() { return true; } final protected function getEditEngineConfiguration() { return $this->editEngineConfiguration; } private function newConfigurationQuery() { return id(new PhabricatorEditEngineConfigurationQuery()) ->setViewer($this->getViewer()) ->withEngineKeys(array($this->getEngineKey())); } private function loadEditEngineConfigurationWithQuery( PhabricatorEditEngineConfigurationQuery $query, $sort_method) { if ($sort_method) { $results = $query->execute(); $results = msort($results, $sort_method); $result = head($results); } else { $result = $query->executeOne(); } if (!$result) { return null; } $this->editEngineConfiguration = $result; return $result; } private function loadEditEngineConfigurationWithIdentifier($identifier) { $query = $this->newConfigurationQuery() ->withIdentifiers(array($identifier)); return $this->loadEditEngineConfigurationWithQuery($query, null); } private function loadDefaultConfiguration() { $query = $this->newConfigurationQuery() ->withIdentifiers( array( self::EDITENGINECONFIG_DEFAULT, )) ->withIgnoreDatabaseConfigurations(true); return $this->loadEditEngineConfigurationWithQuery($query, null); } private function loadDefaultCreateConfiguration() { $query = $this->newConfigurationQuery() ->withIsDefault(true) ->withIsDisabled(false); return $this->loadEditEngineConfigurationWithQuery( $query, 'getCreateSortKey'); } public function loadDefaultEditConfiguration() { $query = $this->newConfigurationQuery() ->withIsEdit(true) ->withIsDisabled(false); return $this->loadEditEngineConfigurationWithQuery( $query, 'getEditSortKey'); } final public function getBuiltinEngineConfigurations() { $configurations = $this->newBuiltinEngineConfigurations(); if (!$configurations) { throw new Exception( pht( 'EditEngine ("%s") returned no builtin engine configurations, but '. 'an edit engine must have at least one configuration.', get_class($this))); } assert_instances_of($configurations, 'PhabricatorEditEngineConfiguration'); $has_default = false; foreach ($configurations as $config) { if ($config->getBuiltinKey() == self::EDITENGINECONFIG_DEFAULT) { $has_default = true; } } if (!$has_default) { $first = head($configurations); if (!$first->getBuiltinKey()) { $first ->setBuiltinKey(self::EDITENGINECONFIG_DEFAULT) ->setIsDefault(true) ->setIsEdit(true); if (!strlen($first->getName())) { $first->setName($this->getObjectCreateShortText()); } } else { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but none are marked as default and the first configuration has '. 'a different builtin key already. Mark a builtin as default or '. 'omit the key from the first configuration', get_class($this))); } } $builtins = array(); foreach ($configurations as $key => $config) { $builtin_key = $config->getBuiltinKey(); if ($builtin_key === null) { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but one (with key "%s") is missing a builtin key. Provide a '. 'builtin key for each configuration (you can omit it from the '. 'first configuration in the list to automatically assign the '. 'default key).', get_class($this), $key)); } if (isset($builtins[$builtin_key])) { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but at least two specify the same builtin key ("%s"). Engines '. 'must have unique builtin keys.', get_class($this), $builtin_key)); } $builtins[$builtin_key] = $config; } return $builtins; } protected function newBuiltinEngineConfigurations() { return array( $this->newConfiguration(), ); } final protected function newConfiguration() { return PhabricatorEditEngineConfiguration::initializeNewConfiguration( $this->getViewer(), $this); } /* -( Managing URIs )------------------------------------------------------ */ /** * @task uri */ abstract protected function getObjectViewURI($object); /** * @task uri */ protected function getObjectCreateCancelURI($object) { return $this->getApplication()->getApplicationURI(); } /** * @task uri */ protected function getEditorURI() { return $this->getApplication()->getApplicationURI('edit/'); } /** * @task uri */ protected function getObjectEditCancelURI($object) { return $this->getObjectViewURI($object); } /** * @task uri */ public function getEditURI($object = null, $path = null) { $parts = array(); $parts[] = $this->getEditorURI(); if ($object && $object->getID()) { $parts[] = $object->getID().'/'; } if ($path !== null) { $parts[] = $path; } return implode('', $parts); } /* -( Creating and Loading Objects )--------------------------------------- */ /** * Initialize a new object for creation. * * @return object Newly initialized object. * @task load */ abstract protected function newEditableObject(); /** * Build an empty query for objects. * * @return PhabricatorPolicyAwareQuery Query. * @task load */ abstract protected function newObjectQuery(); /** * Test if this workflow is creating a new object or editing an existing one. * * @return bool True if a new object is being created. * @task load */ final public function getIsCreate() { return $this->isCreate; } /** * Flag this workflow as a create or edit. * * @param bool True if this is a create workflow. * @return this * @task load */ private function setIsCreate($is_create) { $this->isCreate = $is_create; return $this; } /** * Try to load an object by ID, PHID, or monogram. This is done primarily * to make Conduit a little easier to use. * * @param wild ID, PHID, or monogram. * @param list List of required capability constants, or omit for * defaults. * @return object Corresponding editable object. * @task load */ private function newObjectFromIdentifier( $identifier, array $capabilities = array()) { if (is_int($identifier) || ctype_digit($identifier)) { $object = $this->newObjectFromID($identifier, $capabilities); if (!$object) { throw new Exception( pht( 'No object exists with ID "%s".', $identifier)); } return $object; } $type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN; if (phid_get_type($identifier) != $type_unknown) { $object = $this->newObjectFromPHID($identifier, $capabilities); if (!$object) { throw new Exception( pht( 'No object exists with PHID "%s".', $identifier)); } return $object; } $target = id(new PhabricatorObjectQuery()) ->setViewer($this->getViewer()) ->withNames(array($identifier)) ->executeOne(); if (!$target) { throw new Exception( pht( 'Monogram "%s" does not identify a valid object.', $identifier)); } $expect = $this->newEditableObject(); $expect_class = get_class($expect); $target_class = get_class($target); if ($expect_class !== $target_class) { throw new Exception( pht( 'Monogram "%s" identifies an object of the wrong type. Loaded '. 'object has class "%s", but this editor operates on objects of '. 'type "%s".', $identifier, $target_class, $expect_class)); } // Load the object by PHID using this engine's standard query. This makes // sure it's really valid, goes through standard policy check logic, and // picks up any `need...()` clauses we want it to load with. $object = $this->newObjectFromPHID($target->getPHID(), $capabilities); if (!$object) { throw new Exception( pht( 'Failed to reload object identified by monogram "%s" when '. 'querying by PHID.', $identifier)); } return $object; } /** * Load an object by ID. * * @param int Object ID. * @param list List of required capability constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromID($id, array $capabilities = array()) { $query = $this->newObjectQuery() ->withIDs(array($id)); return $this->newObjectFromQuery($query, $capabilities); } /** * Load an object by PHID. * * @param phid Object PHID. * @param list List of required capability constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromPHID($phid, array $capabilities = array()) { $query = $this->newObjectQuery() ->withPHIDs(array($phid)); return $this->newObjectFromQuery($query, $capabilities); } /** * Load an object given a configured query. * * @param PhabricatorPolicyAwareQuery Configured query. * @param list List of required capabilitiy constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromQuery( PhabricatorPolicyAwareQuery $query, array $capabilities = array()) { $viewer = $this->getViewer(); if (!$capabilities) { $capabilities = array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } $object = $query ->setViewer($viewer) ->requireCapabilities($capabilities) ->executeOne(); if (!$object) { return null; } return $object; } /** * Verify that an object is appropriate for editing. * * @param wild Loaded value. * @return void * @task load */ private function validateObject($object) { if (!$object || !is_object($object)) { throw new Exception( pht( 'EditEngine "%s" created or loaded an invalid object: object must '. 'actually be an object, but is of some other type ("%s").', get_class($this), gettype($object))); } if (!($object instanceof PhabricatorApplicationTransactionInterface)) { throw new Exception( pht( 'EditEngine "%s" created or loaded an invalid object: object (of '. 'class "%s") must implement "%s", but does not.', get_class($this), get_class($object), 'PhabricatorApplicationTransactionInterface')); } } /* -( Responding to Web Requests )----------------------------------------- */ final public function buildResponse() { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $action = $request->getURIData('editAction'); $capabilities = array(); $use_default = false; $require_create = true; switch ($action) { case 'comment': $capabilities = array( PhabricatorPolicyCapability::CAN_VIEW, ); $use_default = true; break; case 'parameters': $use_default = true; break; case 'nodefault': case 'nocreate': case 'nomanage': $require_create = false; break; default: break; } $id = $request->getURIData('id'); if ($id) { $this->setIsCreate(false); $object = $this->newObjectFromID($id, $capabilities); if (!$object) { return new Aphront404Response(); } } else { // Make sure the viewer has permission to create new objects of // this type if we're going to create a new object. if ($require_create) { $this->requireCreateCapability(); } $this->setIsCreate(true); $object = $this->newEditableObject(); } $this->validateObject($object); if ($use_default) { $config = $this->loadDefaultConfiguration(); if (!$config) { return new Aphront404Response(); } } else { $form_key = $request->getURIData('formKey'); if (strlen($form_key)) { $config = $this->loadEditEngineConfigurationWithIdentifier($form_key); if (!$config) { return new Aphront404Response(); } if ($id && !$config->getIsEdit()) { return $this->buildNotEditFormRespose($object, $config); } } else { if ($id) { $config = $this->loadDefaultEditConfiguration(); if (!$config) { return $this->buildNoEditResponse($object); } } else { $config = $this->loadDefaultCreateConfiguration(); if (!$config) { return $this->buildNoCreateResponse($object); } } } } if ($config->getIsDisabled()) { return $this->buildFormDisabledResponse($object, $config); } switch ($action) { case 'parameters': return $this->buildParametersResponse($object); case 'nodefault': return $this->buildNoDefaultResponse($object); case 'nocreate': return $this->buildNoCreateResponse($object); case 'nomanage': return $this->buildNoManageResponse($object); case 'comment': return $this->buildCommentResponse($object); default: return $this->buildEditResponse($object); } } private function buildCrumbs($object, $final = false) { $controller = $this->getcontroller(); $crumbs = $controller->buildApplicationCrumbsForEditEngine(); if ($this->getIsCreate()) { $create_text = $this->getObjectCreateShortText(); if ($final) { $crumbs->addTextCrumb($create_text); } else { $edit_uri = $this->getEditURI($object); $crumbs->addTextCrumb($create_text, $edit_uri); } } else { $crumbs->addTextCrumb( $this->getObjectEditShortText($object), $this->getObjectViewURI($object)); $edit_text = pht('Edit'); if ($final) { $crumbs->addTextCrumb($edit_text); } else { $edit_uri = $this->getEditURI($object); $crumbs->addTextCrumb($edit_text, $edit_uri); } } return $crumbs; } private function buildEditResponse($object) { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $fields = $this->buildEditFields($object); $template = $object->getApplicationTransactionTemplate(); $validation_exception = null; if ($request->isFormPost()) { foreach ($fields as $field) { $field->setIsSubmittedForm(true); if ($field->getIsLocked() || $field->getIsHidden()) { continue; } $field->readValueFromSubmit($request); } $xactions = array(); foreach ($fields as $field) { $types = $field->getWebEditTypes(); foreach ($types as $type) { $type_xactions = $type->generateTransactions( clone $template, array( 'value' => $field->getValueForTransaction(), )); if (!$type_xactions) { continue; } foreach ($type_xactions as $type_xaction) { $xactions[] = $type_xaction; } } } $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true); try { $editor->applyTransactions($object, $xactions); return $this->newEditResponse($request, $object, $xactions); } catch (PhabricatorApplicationTransactionValidationException $ex) { $validation_exception = $ex; foreach ($fields as $field) { $xaction_type = $field->getTransactionType(); if ($xaction_type === null) { continue; } $message = $ex->getShortMessage($xaction_type); if ($message === null) { continue; } $field->setControlError($message); } } } else { if ($this->getIsCreate()) { $template = $request->getStr('template'); if (strlen($template)) { $template_object = $this->newObjectFromIdentifier( $template, array( PhabricatorPolicyCapability::CAN_VIEW, )); if (!$template_object) { return new Aphront404Response(); } } else { $template_object = null; } if ($template_object) { $copy_fields = $this->buildEditFields($template_object); $copy_fields = mpull($copy_fields, null, 'getKey'); foreach ($copy_fields as $copy_key => $copy_field) { if (!$copy_field->getIsCopyable()) { unset($copy_fields[$copy_key]); } } } else { $copy_fields = array(); } foreach ($fields as $field) { if ($field->getIsLocked() || $field->getIsHidden()) { continue; } $field_key = $field->getKey(); if (isset($copy_fields[$field_key])) { $field->readValueFromField($copy_fields[$field_key]); } $field->readValueFromRequest($request); } } } $action_button = $this->buildEditFormActionButton($object); if ($this->getIsCreate()) { $header_text = $this->getFormHeaderText($object); } else { $header_text = $this->getObjectEditTitleText($object); } $form = $this->buildEditForm($object, $fields); if ($request->isAjax()) { if ($this->getIsCreate()) { $cancel_uri = $this->getObjectCreateCancelURI($object); $submit_button = $this->getObjectCreateButtonText($object); } else { $cancel_uri = $this->getObjectEditCancelURI($object); $submit_button = $this->getObjectEditButtonText($object); } return $this->getController() ->newDialog() ->setWidth(AphrontDialogView::WIDTH_FULL) ->setTitle($header_text) ->setValidationException($validation_exception) ->appendForm($form) ->addCancelButton($cancel_uri) ->addSubmitButton($submit_button); } $header = id(new PHUIHeaderView()) ->setHeader($header_text) ->addActionLink($action_button); $crumbs = $this->buildCrumbs($object, $final = true); $box = id(new PHUIObjectBoxView()) ->setUser($viewer) ->setHeader($header) ->setValidationException($validation_exception) ->appendChild($form); return $controller->newPage() ->setTitle($header_text) ->setCrumbs($crumbs) ->appendChild($box); } protected function newEditResponse( AphrontRequest $request, $object, array $xactions) { return id(new AphrontRedirectResponse()) ->setURI($this->getObjectViewURI($object)); } private function buildEditForm($object, array $fields) { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $form = id(new AphrontFormView()) ->setUser($viewer); foreach ($this->contextParameters as $param) { $form->addHiddenInput($param, $request->getStr($param)); } foreach ($fields as $field) { $field->appendToForm($form); } if ($this->getIsCreate()) { $cancel_uri = $this->getObjectCreateCancelURI($object); $submit_button = $this->getObjectCreateButtonText($object); } else { $cancel_uri = $this->getObjectEditCancelURI($object); $submit_button = $this->getObjectEditButtonText($object); } if (!$request->isAjax()) { $form->appendControl( id(new AphrontFormSubmitControl()) ->addCancelButton($cancel_uri) ->setValue($submit_button)); } return $form; } private function buildEditFormActionButton($object) { $viewer = $this->getViewer(); $action_view = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($this->buildEditFormActions($object) as $action) { $action_view->addAction($action); } $action_button = id(new PHUIButtonView()) ->setTag('a') ->setText(pht('Actions')) ->setHref('#') ->setIconFont('fa-bars') ->setDropdownMenu($action_view); return $action_button; } private function buildEditFormActions($object) { $actions = array(); $actions[] = id(new PhabricatorActionView()) ->setName(pht('Show HTTP Parameters')) ->setIcon('fa-crosshairs') ->setHref($this->getEditURI($object, 'parameters/')); if ($this->supportsEditEngineConfiguration()) { $engine_key = $this->getEngineKey(); $config = $this->getEditEngineConfiguration(); $can_manage = PhabricatorPolicyFilter::hasCapability( $this->getViewer(), $config, PhabricatorPolicyCapability::CAN_EDIT); if ($can_manage) { $manage_uri = $config->getURI(); } else { $manage_uri = $this->getEditURI(null, 'nomanage/'); } $view_uri = "/transactions/editengine/{$engine_key}/"; $actions[] = id(new PhabricatorActionView()) ->setName(pht('View Form Configurations')) ->setIcon('fa-list-ul') ->setHref($view_uri); $actions[] = id(new PhabricatorActionView()) ->setName(pht('Edit Form Configuration')) ->setIcon('fa-pencil') ->setHref($manage_uri) ->setDisabled(!$can_manage) ->setWorkflow(!$can_manage); } return $actions; } final public function addActionToCrumbs(PHUICrumbsView $crumbs) { $viewer = $this->getViewer(); $can_create = $this->hasCreateCapability(); if ($can_create) { $configs = $this->loadUsableConfigurationsForCreate(); } else { $configs = array(); } $dropdown = null; $disabled = false; $workflow = false; $menu_icon = 'fa-plus-square'; if (!$configs) { if ($viewer->isLoggedIn()) { $disabled = true; } else { // If the viewer isn't logged in, assume they'll get hit with a login // dialog and are likely able to create objects after they log in. $disabled = false; } $workflow = true; if ($can_create) { $create_uri = $this->getEditURI(null, 'nodefault/'); } else { $create_uri = $this->getEditURI(null, 'nocreate/'); } } else { $config = head($configs); $form_key = $config->getIdentifier(); $create_uri = $this->getEditURI(null, "form/{$form_key}/"); if (count($configs) > 1) { $menu_icon = 'fa-caret-square-o-down'; $dropdown = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($configs as $config) { $form_key = $config->getIdentifier(); $config_uri = $this->getEditURI(null, "form/{$form_key}/"); $item_icon = 'fa-plus'; $dropdown->addAction( id(new PhabricatorActionView()) ->setName($config->getDisplayName()) ->setIcon($item_icon) ->setHref($config_uri)); } } } $action = id(new PHUIListItemView()) ->setName($this->getObjectCreateShortText()) ->setHref($create_uri) ->setIcon($menu_icon) ->setWorkflow($workflow) ->setDisabled($disabled); if ($dropdown) { $action->setDropdownMenu($dropdown); } $crumbs->addAction($action); } final public function buildEditEngineCommentView($object) { $config = $this->loadDefaultEditConfiguration(); if (!$config) { // TODO: This just nukes the entire comment form if you don't have access // to any edit forms. We might want to tailor this UX a bit. return id(new PhabricatorApplicationTransactionCommentView()) ->setNoPermission(true); } $viewer = $this->getViewer(); $object_phid = $object->getPHID(); $header_text = $this->getCommentViewHeaderText($object); $button_text = $this->getCommentViewButtonText($object); $comment_uri = $this->getEditURI($object, 'comment/'); $view = id(new PhabricatorApplicationTransactionCommentView()) ->setUser($viewer) ->setObjectPHID($object_phid) ->setHeaderText($header_text) ->setAction($comment_uri) ->setSubmitButtonName($button_text); $draft = PhabricatorVersionedDraft::loadDraft( $object_phid, $viewer->getPHID()); if ($draft) { $view->setVersionedDraft($draft); } $view->setCurrentVersion($this->loadDraftVersion($object)); $fields = $this->buildEditFields($object); $all_types = array(); foreach ($fields as $field) { - // TODO: Load draft stuff. + if (!$this->isCommentField($field)) { + continue; + } + $types = $field->getCommentEditTypes(); foreach ($types as $type) { $all_types[] = $type; } } $view->setEditTypes($all_types); return $view; } protected function loadDraftVersion($object) { $viewer = $this->getViewer(); if (!$viewer->isLoggedIn()) { return null; } $template = $object->getApplicationTransactionTemplate(); $conn_r = $template->establishConnection('r'); // Find the most recent transaction the user has written. We'll use this // as a version number to make sure that out-of-date drafts get discarded. $result = queryfx_one( $conn_r, 'SELECT id AS version FROM %T WHERE objectPHID = %s AND authorPHID = %s ORDER BY id DESC LIMIT 1', $template->getTableName(), $object->getPHID(), $viewer->getPHID()); if ($result) { return (int)$result['version']; } else { return null; } } /* -( Responding to HTTP Parameter Requests )------------------------------ */ /** * Respond to a request for documentation on HTTP parameters. * * @param object Editable object. * @return AphrontResponse Response object. * @task http */ private function buildParametersResponse($object) { $controller = $this->getController(); $viewer = $this->getViewer(); $request = $controller->getRequest(); $fields = $this->buildEditFields($object); $crumbs = $this->buildCrumbs($object); $crumbs->addTextCrumb(pht('HTTP Parameters')); $crumbs->setBorder(true); $header_text = pht( 'HTTP Parameters: %s', $this->getObjectCreateShortText()); $header = id(new PHUIHeaderView()) ->setHeader($header_text); $help_view = id(new PhabricatorApplicationEditHTTPParameterHelpView()) ->setUser($viewer) ->setFields($fields); $document = id(new PHUIDocumentViewPro()) ->setUser($viewer) ->setHeader($header) ->appendChild($help_view); return $controller->newPage() ->setTitle(pht('HTTP Parameters')) ->setCrumbs($crumbs) ->appendChild($document); } private function buildError($object, $title, $body) { $cancel_uri = $this->getObjectCreateCancelURI($object); return $this->getController() ->newDialog() ->setTitle($title) ->appendParagraph($body) ->addCancelButton($cancel_uri); } private function buildNoDefaultResponse($object) { return $this->buildError( $object, pht('No Default Create Forms'), pht( 'This application is not configured with any forms for creating '. 'objects that are visible to you and enabled.')); } private function buildNoCreateResponse($object) { return $this->buildError( $object, pht('No Create Permission'), pht('You do not have permission to create these objects.')); } private function buildNoManageResponse($object) { return $this->buildError( $object, pht('No Manage Permission'), pht( 'You do not have permission to configure forms for this '. 'application.')); } private function buildNoEditResponse($object) { return $this->buildError( $object, pht('No Edit Forms'), pht( 'You do not have access to any forms which are enabled and marked '. 'as edit forms.')); } private function buildNotEditFormRespose($object, $config) { return $this->buildError( $object, pht('Not an Edit Form'), pht( 'This form ("%s") is not marked as an edit form, so '. 'it can not be used to edit objects.', $config->getName())); } private function buildDisabledFormResponse($object, $config) { return $this->buildError( $object, pht('Form Disabled'), pht( 'This form ("%s") has been disabled, so it can not be used.', $config->getName())); } private function buildCommentResponse($object) { $viewer = $this->getViewer(); if ($this->getIsCreate()) { return new Aphront404Response(); } $controller = $this->getController(); $request = $controller->getRequest(); if (!$request->isFormPost()) { return new Aphront400Response(); } $config = $this->loadDefaultEditConfiguration(); if (!$config) { return new Aphront404Response(); } $fields = $this->buildEditFields($object); $is_preview = $request->isPreviewRequest(); $view_uri = $this->getObjectViewURI($object); $template = $object->getApplicationTransactionTemplate(); $comment_template = $template->getApplicationTransactionCommentObject(); $comment_text = $request->getStr('comment'); $actions = $request->getStr('editengine.actions'); if ($actions) { $actions = phutil_json_decode($actions); } if ($is_preview) { $version_key = PhabricatorVersionedDraft::KEY_VERSION; $request_version = $request->getInt($version_key); $current_version = $this->loadDraftVersion($object); if ($request_version >= $current_version) { $draft = PhabricatorVersionedDraft::loadOrCreateDraft( $object->getPHID(), $viewer->getPHID(), $current_version); - // TODO: This is just a proof of concept. $draft - ->setProperty('temporary.comment', $comment_text) + ->setProperty('comment', $comment_text) ->setProperty('actions', $actions) ->save(); } } $xactions = array(); if ($actions) { $type_map = array(); foreach ($fields as $field) { + if (!$this->isCommentField($field)) { + continue; + } + $types = $field->getCommentEditTypes(); foreach ($types as $type) { $type_map[$type->getEditType()] = array( 'type' => $type, 'field' => $field, ); } } foreach ($actions as $action) { $type = idx($action, 'type'); if (!$type) { continue; } $spec = idx($type_map, $type); if (!$spec) { continue; } $edit_type = $spec['type']; $field = $spec['field']; $field->readValueFromComment($action); $type_xactions = $edit_type->generateTransactions( $template, array( 'value' => $field->getValueForTransaction(), )); foreach ($type_xactions as $type_xaction) { $xactions[] = $type_xaction; } } } if (strlen($comment_text) || !$xactions) { $xactions[] = id(clone $template) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment( id(clone $comment_template) ->setContent($comment_text)); } $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContinueOnNoEffect($request->isContinueRequest()) ->setContentSourceFromRequest($request) ->setIsPreview($is_preview); try { $xactions = $editor->applyTransactions($object, $xactions); } catch (PhabricatorApplicationTransactionNoEffectException $ex) { return id(new PhabricatorApplicationTransactionNoEffectResponse()) ->setCancelURI($view_uri) ->setException($ex); } if (!$is_preview) { PhabricatorVersionedDraft::purgeDrafts( $object->getPHID(), $viewer->getPHID(), $this->loadDraftVersion($object)); } if ($request->isAjax() && $is_preview) { return id(new PhabricatorApplicationTransactionResponse()) ->setViewer($viewer) ->setTransactions($xactions) ->setIsPreview($is_preview); } else { return id(new AphrontRedirectResponse()) ->setURI($view_uri); } } /* -( Conduit )------------------------------------------------------------ */ /** * Respond to a Conduit edit request. * * This method accepts a list of transactions to apply to an object, and * either edits an existing object or creates a new one. * * @task conduit */ final public function buildConduitResponse(ConduitAPIRequest $request) { $viewer = $this->getViewer(); $config = $this->loadDefaultConfiguration(); if (!$config) { throw new Exception( pht( 'Unable to load configuration for this EditEngine ("%s").', get_class($this))); } $identifier = $request->getValue('objectIdentifier'); if ($identifier) { $this->setIsCreate(false); $object = $this->newObjectFromIdentifier($identifier); } else { $this->requireCreateCapability(); $this->setIsCreate(true); $object = $this->newEditableObject(); } $this->validateObject($object); $fields = $this->buildEditFields($object); $types = $this->getConduitEditTypesFromFields($fields); $template = $object->getApplicationTransactionTemplate(); $xactions = $this->getConduitTransactions($request, $types, $template); $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromConduitRequest($request) ->setContinueOnNoEffect(true); $xactions = $editor->applyTransactions($object, $xactions); $xactions_struct = array(); foreach ($xactions as $xaction) { $xactions_struct[] = array( 'phid' => $xaction->getPHID(), ); } return array( 'object' => array( 'id' => $object->getID(), 'phid' => $object->getPHID(), ), 'transactions' => $xactions_struct, ); } /** * Generate transactions which can be applied from edit actions in a Conduit * request. * * @param ConduitAPIRequest The request. * @param list Supported edit types. * @param PhabricatorApplicationTransaction Template transaction. * @return list Generated transactions. * @task conduit */ private function getConduitTransactions( ConduitAPIRequest $request, array $types, PhabricatorApplicationTransaction $template) { $transactions_key = 'transactions'; $xactions = $request->getValue($transactions_key); if (!is_array($xactions)) { throw new Exception( pht( 'Parameter "%s" is not a list of transactions.', $transactions_key)); } foreach ($xactions as $key => $xaction) { if (!is_array($xaction)) { throw new Exception( pht( 'Parameter "%s" must contain a list of transaction descriptions, '. 'but item with key "%s" is not a dictionary.', $transactions_key, $key)); } if (!array_key_exists('type', $xaction)) { throw new Exception( pht( 'Parameter "%s" must contain a list of transaction descriptions, '. 'but item with key "%s" is missing a "type" field. Each '. 'transaction must have a type field.', $transactions_key, $key)); } $type = $xaction['type']; if (empty($types[$type])) { throw new Exception( pht( 'Transaction with key "%s" has invalid type "%s". This type is '. 'not recognized. Valid types are: %s.', $key, $type, implode(', ', array_keys($types)))); } } $results = array(); foreach ($xactions as $xaction) { $type = $types[$xaction['type']]; $type_xactions = $type->generateTransactions( clone $template, $xaction); foreach ($type_xactions as $type_xaction) { $results[] = $type_xaction; } } return $results; } /** * @return map * @task conduit */ private function getConduitEditTypesFromFields(array $fields) { $types = array(); foreach ($fields as $field) { $field_types = $field->getConduitEditTypes(); if ($field_types === null) { continue; } foreach ($field_types as $field_type) { $field_type->setField($field); $types[$field_type->getEditType()] = $field_type; } } return $types; } public function getConduitEditTypes() { $config = $this->loadDefaultConfiguration(); if (!$config) { return array(); } $object = $this->newEditableObject(); $fields = $this->buildEditFields($object); return $this->getConduitEditTypesFromFields($fields); } final public static function getAllEditEngines() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getEngineKey') ->execute(); } final public static function getByKey(PhabricatorUser $viewer, $key) { return id(new PhabricatorEditEngineQuery()) ->setViewer($viewer) ->withEngineKeys(array($key)) ->executeOne(); } public function getIcon() { $application = $this->getApplication(); return $application->getFontIcon(); } public function loadQuickCreateItems() { $items = array(); if (!$this->hasCreateCapability()) { return $items; } $configs = $this->loadUsableConfigurationsForCreate(); if (!$configs) { // No items to add. } else if (count($configs) == 1) { $config = head($configs); $items[] = $this->newQuickCreateItem($config); } else { $group_name = $this->getQuickCreateMenuHeaderText(); $items[] = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_LABEL) ->setName($group_name); foreach ($configs as $config) { $items[] = $this->newQuickCreateItem($config); } } return $items; } private function loadUsableConfigurationsForCreate() { $viewer = $this->getViewer(); $configs = id(new PhabricatorEditEngineConfigurationQuery()) ->setViewer($viewer) ->withEngineKeys(array($this->getEngineKey())) ->withIsDefault(true) ->withIsDisabled(false) ->execute(); $configs = msort($configs, 'getCreateSortKey'); return $configs; } private function newQuickCreateItem( PhabricatorEditEngineConfiguration $config) { $item_name = $config->getName(); $item_icon = $config->getIcon(); $form_key = $config->getIdentifier(); $item_uri = $this->getEditURI(null, "form/{$form_key}/"); return id(new PHUIListItemView()) ->setName($item_name) ->setIcon($item_icon) ->setHref($item_uri); } protected function getCreateNewObjectPolicy() { return PhabricatorPolicies::POLICY_USER; } private function requireCreateCapability() { PhabricatorPolicyFilter::requireCapability( $this->getViewer(), $this, PhabricatorPolicyCapability::CAN_EDIT); } private function hasCreateCapability() { return PhabricatorPolicyFilter::hasCapability( $this->getViewer(), $this, PhabricatorPolicyCapability::CAN_EDIT); } + private function isCommentField(PhabricatorEditField $field) { + // TODO: This is a little bit hacky. + if ($field->getKey() == 'comment') { + return true; + } + + if ($field->getIsLocked()) { + return false; + } + + if ($field->getIsHidden()) { + return false; + } + + return true; + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getPHID() { return get_class($this); } public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getCreateNewObjectPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } public function describeAutomaticCapability($capability) { return null; } } diff --git a/src/applications/transactions/editengineextension/PhabricatorCommentEditEngineExtension.php b/src/applications/transactions/editengineextension/PhabricatorCommentEditEngineExtension.php index 6837d8e933..125ac32b33 100644 --- a/src/applications/transactions/editengineextension/PhabricatorCommentEditEngineExtension.php +++ b/src/applications/transactions/editengineextension/PhabricatorCommentEditEngineExtension.php @@ -1,55 +1,58 @@ getApplicationTransactionTemplate(); try { $comment = $xaction->getApplicationTransactionCommentObject(); } catch (PhutilMethodNotImplementedException $ex) { $comment = null; } return (bool)$comment; } public function buildCustomEditFields( PhabricatorEditEngine $engine, PhabricatorApplicationTransactionInterface $object) { $comment_type = PhabricatorTransactions::TYPE_COMMENT; $comment_field = id(new PhabricatorCommentEditField()) ->setKey('comment') ->setLabel(pht('Comments')) ->setDescription(pht('Add comments.')) ->setAliases(array('comments')) ->setIsHidden(true) + ->setIsReorderable(false) + ->setIsDefaultable(false) + ->setIsLockable(false) ->setTransactionType($comment_type) ->setValue(null); return array( $comment_field, ); } } diff --git a/src/applications/transactions/editfield/PhabricatorHandlesEditField.php b/src/applications/transactions/editfield/PhabricatorHandlesEditField.php index cc770cd011..79e595f25f 100644 --- a/src/applications/transactions/editfield/PhabricatorHandlesEditField.php +++ b/src/applications/transactions/editfield/PhabricatorHandlesEditField.php @@ -1,14 +1,47 @@ handleParameterType = $type; + return $this; + } + + public function getHandleParameterType() { + return $this->handleParameterType; + } + + public function setIsInvisible($is_invisible) { + $this->isInvisible = $is_invisible; + return $this; + } + + public function getIsInvisible() { + return $this->isInvisible; + } + protected function newControl() { - return id(new AphrontFormHandlesControl()); + $control = id(new AphrontFormHandlesControl()); + + if ($this->getIsInvisible()) { + $control->setIsInvisible(true); + } + + return $control; } protected function newHTTPParameterType() { - return new ManiphestTaskListHTTPParameterType(); + $type = $this->getHandleParameterType(); + + if ($type) { + return $type; + } + + return new AphrontPHIDListHTTPParameterType(); } } diff --git a/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php b/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php index 13da4cbd68..c33ea44be2 100644 --- a/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php +++ b/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php @@ -1,380 +1,380 @@ objectPHID = $object_phid; return $this; } public function getObjectPHID() { return $this->objectPHID; } public function setShowPreview($show_preview) { $this->showPreview = $show_preview; return $this; } public function getShowPreview() { return $this->showPreview; } public function setRequestURI(PhutilURI $request_uri) { $this->requestURI = $request_uri; return $this; } public function getRequestURI() { return $this->requestURI; } public function setCurrentVersion($current_version) { $this->currentVersion = $current_version; return $this; } public function getCurrentVersion() { return $this->currentVersion; } public function setVersionedDraft( PhabricatorVersionedDraft $versioned_draft) { $this->versionedDraft = $versioned_draft; return $this; } public function getVersionedDraft() { return $this->versionedDraft; } public function setDraft(PhabricatorDraft $draft) { $this->draft = $draft; return $this; } public function getDraft() { return $this->draft; } public function setSubmitButtonName($submit_button_name) { $this->submitButtonName = $submit_button_name; return $this; } public function getSubmitButtonName() { return $this->submitButtonName; } public function setAction($action) { $this->action = $action; return $this; } public function getAction() { return $this->action; } public function setHeaderText($text) { $this->headerText = $text; return $this; } public function setEditTypes($edit_types) { $this->editTypes = $edit_types; return $this; } public function getEditTypes() { return $this->editTypes; } public function setNoPermission($no_permission) { $this->noPermission = $no_permission; return $this; } public function getNoPermission() { return $this->noPermission; } public function setTransactionTimeline( PhabricatorApplicationTransactionView $timeline) { $timeline->setQuoteTargetID($this->getCommentID()); if ($this->getNoPermission()) { $timeline->setShouldTerminate(true); } $this->transactionTimeline = $timeline; return $this; } public function render() { if ($this->getNoPermission()) { return null; } $user = $this->getUser(); if (!$user->isLoggedIn()) { $uri = id(new PhutilURI('/login/')) ->setQueryParam('next', (string)$this->getRequestURI()); return id(new PHUIObjectBoxView()) ->setFlush(true) ->setHeaderText(pht('Add Comment')) ->appendChild( javelin_tag( 'a', array( 'class' => 'login-to-comment button', 'href' => $uri, ), pht('Login to Comment'))); } $data = array(); $comment = $this->renderCommentPanel(); if ($this->getShowPreview()) { $preview = $this->renderPreviewPanel(); } else { $preview = null; } if (!$this->getEditTypes()) { Javelin::initBehavior( 'phabricator-transaction-comment-form', array( 'formID' => $this->getFormID(), 'timelineID' => $this->getPreviewTimelineID(), 'panelID' => $this->getPreviewPanelID(), 'showPreview' => $this->getShowPreview(), 'actionURI' => $this->getAction(), )); } $comment_box = id(new PHUIObjectBoxView()) ->setFlush(true) ->setHeaderText($this->headerText) ->appendChild($comment); return array($comment_box, $preview); } private function renderCommentPanel() { $draft_comment = ''; $draft_key = null; if ($this->getDraft()) { $draft_comment = $this->getDraft()->getDraft(); $draft_key = $this->getDraft()->getDraftKey(); } $versioned_draft = $this->getVersionedDraft(); if ($versioned_draft) { - $draft_comment = $versioned_draft->getProperty('temporary.comment', ''); + $draft_comment = $versioned_draft->getProperty('comment', ''); } if (!$this->getObjectPHID()) { throw new PhutilInvalidStateException('setObjectPHID', 'render'); } $version_key = PhabricatorVersionedDraft::KEY_VERSION; $version_value = $this->getCurrentVersion(); $form = id(new AphrontFormView()) ->setUser($this->getUser()) ->addSigil('transaction-append') ->setWorkflow(true) ->setMetadata( array( 'objectPHID' => $this->getObjectPHID(), )) ->setAction($this->getAction()) ->setID($this->getFormID()) ->addHiddenInput('__draft__', $draft_key) ->addHiddenInput($version_key, $version_value); $edit_types = $this->getEditTypes(); if ($edit_types) { $action_map = array(); $type_map = array(); foreach ($edit_types as $edit_type) { $key = $edit_type->getEditType(); $action_map[$key] = array( 'key' => $key, 'label' => $edit_type->getLabel(), 'type' => $edit_type->getPHUIXControlType(), 'spec' => $edit_type->getPHUIXControlSpecification(), ); $type_map[$key] = $edit_type; } $options = array(); $options['+'] = pht('Add Action...'); foreach ($action_map as $key => $item) { $options[$key] = $item['label']; } $action_id = celerity_generate_unique_node_id(); $input_id = celerity_generate_unique_node_id(); $place_id = celerity_generate_unique_node_id(); $form->appendChild( phutil_tag( 'input', array( 'type' => 'hidden', 'name' => 'editengine.actions', 'id' => $input_id, ))); $form->appendChild( id(new AphrontFormSelectControl()) ->setLabel(pht('Actions')) ->setID($action_id) ->setOptions($options)); // This is an empty placeholder node so we know where to insert the // new actions. $form->appendChild( phutil_tag( 'div', array( 'id' => $place_id, ))); $draft_actions = array(); if ($versioned_draft) { $draft_actions = $versioned_draft->getProperty('actions', array()); foreach ($draft_actions as $key => $action) { $type = idx($action, 'type'); if (!$type) { unset($draft_actions[$key]); continue; } $edit_type = idx($type_map, $type); if (!$edit_type) { unset($draft_actions[$key]); continue; } $value = idx($action, 'value'); $value = $edit_type->getCommentActionValueFromDraftValue($value); $draft_actions[$key]['value'] = $value; } } Javelin::initBehavior( 'comment-actions', array( 'actionID' => $action_id, 'inputID' => $input_id, 'formID' => $this->getFormID(), 'placeID' => $place_id, 'panelID' => $this->getPreviewPanelID(), 'timelineID' => $this->getPreviewTimelineID(), 'actions' => $action_map, 'showPreview' => $this->getShowPreview(), 'actionURI' => $this->getAction(), 'drafts' => $draft_actions, )); } $form ->appendChild( id(new PhabricatorRemarkupControl()) ->setID($this->getCommentID()) ->setName('comment') ->setLabel(pht('Comment')) ->setUser($this->getUser()) ->setValue($draft_comment)) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue($this->getSubmitButtonName())); return $form; } private function renderPreviewPanel() { $preview = id(new PHUITimelineView()) ->setID($this->getPreviewTimelineID()); return phutil_tag( 'div', array( 'id' => $this->getPreviewPanelID(), 'style' => 'display: none', ), $preview); } private function getPreviewPanelID() { if (!$this->previewPanelID) { $this->previewPanelID = celerity_generate_unique_node_id(); } return $this->previewPanelID; } private function getPreviewTimelineID() { if (!$this->previewTimelineID) { $this->previewTimelineID = celerity_generate_unique_node_id(); } return $this->previewTimelineID; } public function setFormID($id) { $this->formID = $id; return $this; } private function getFormID() { if (!$this->formID) { $this->formID = celerity_generate_unique_node_id(); } return $this->formID; } private function getStatusID() { if (!$this->statusID) { $this->statusID = celerity_generate_unique_node_id(); } return $this->statusID; } private function getCommentID() { if (!$this->commentID) { $this->commentID = celerity_generate_unique_node_id(); } return $this->commentID; } } diff --git a/src/view/form/control/AphrontFormHandlesControl.php b/src/view/form/control/AphrontFormHandlesControl.php index bd6fa1a16d..b7b4c2de1c 100644 --- a/src/view/form/control/AphrontFormHandlesControl.php +++ b/src/view/form/control/AphrontFormHandlesControl.php @@ -1,36 +1,65 @@ isInvisible = $is_invisible; + return $this; + } + + public function getIsInvisible() { + return $this->isInvisible; + } + protected function shouldRender() { return (bool)$this->getValue(); } + public function getLabel() { + // TODO: This is a bit funky and still rendering a few pixels of padding + // on the form, but there's currently no way to get a control to only emit + // hidden inputs. Clean this up eventually. + + if ($this->getIsInvisible()) { + return null; + } + + return parent::getLabel(); + } + protected function renderInput() { $value = $this->getValue(); $viewer = $this->getUser(); - $list = $viewer->renderHandleList($value); - $list = id(new PHUIBoxView()) - ->addPadding(PHUI::PADDING_SMALL_TOP) - ->appendChild($list); + $out = array(); + + if (!$this->getIsInvisible()) { + $list = $viewer->renderHandleList($value); + $list = id(new PHUIBoxView()) + ->addPadding(PHUI::PADDING_SMALL_TOP) + ->appendChild($list); + $out[] = $list; + } $inputs = array(); foreach ($value as $phid) { $inputs[] = phutil_tag( 'input', array( 'type' => 'hidden', 'name' => $this->getName().'[]', 'value' => $phid, )); } + $out[] = $inputs; - return array($list, $inputs); + return $out; } }