1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-30 10:42:41 +01:00

Add very basic UI for creating milestones and subprojects

Summary:
Ref T10010. This has a lot of UI/UX problems but I think it:

  - technically allows subproject creation;
  - technically allows milestone creation;
  - doesn't let users unwittingly destroy their installs (probably).

Test Plan:
  - Created milestones.
  - Created subprojects.
  - Created and edited normal projects.
  - Observed some reasonable interactions (e.g., you can't create milestones for a milestone or edit a superproject's members).
  - Observed plenty of silly/confusing interactions that need additional work.

{F1046657}

{F1046658}

{F1046655}

{F1046656}

{F1046654}

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T10010

Differential Revision: https://secure.phabricator.com/D14904
This commit is contained in:
epriestley 2015-12-27 05:16:36 -08:00
parent 7732f9c03c
commit 7c5ad63fd1
14 changed files with 583 additions and 84 deletions

View file

@ -7,7 +7,7 @@
*/ */
return array( return array(
'names' => array( 'names' => array(
'core.pkg.css' => 'a419cf4b', 'core.pkg.css' => '3ea6dc33',
'core.pkg.js' => '57dff7df', 'core.pkg.js' => '57dff7df',
'darkconsole.pkg.js' => 'e7393ebb', 'darkconsole.pkg.js' => 'e7393ebb',
'differential.pkg.css' => '2de124c9', 'differential.pkg.css' => '2de124c9',
@ -114,7 +114,7 @@ return array(
'rsrc/css/font/phui-font-icon-base.css' => 'ecbbb4c2', 'rsrc/css/font/phui-font-icon-base.css' => 'ecbbb4c2',
'rsrc/css/layout/phabricator-filetree-view.css' => 'fccf9f82', 'rsrc/css/layout/phabricator-filetree-view.css' => 'fccf9f82',
'rsrc/css/layout/phabricator-hovercard-view.css' => '1239cd52', 'rsrc/css/layout/phabricator-hovercard-view.css' => '1239cd52',
'rsrc/css/layout/phabricator-side-menu-view.css' => 'bec2458e', 'rsrc/css/layout/phabricator-side-menu-view.css' => '91b7a42c',
'rsrc/css/layout/phabricator-source-code-view.css' => 'cbeef983', 'rsrc/css/layout/phabricator-source-code-view.css' => 'cbeef983',
'rsrc/css/phui/calendar/phui-calendar-day.css' => 'd1cf6f93', 'rsrc/css/phui/calendar/phui-calendar-day.css' => 'd1cf6f93',
'rsrc/css/phui/calendar/phui-calendar-list.css' => 'c1c7f338', 'rsrc/css/phui/calendar/phui-calendar-list.css' => 'c1c7f338',
@ -762,7 +762,7 @@ return array(
'phabricator-remarkup-css' => '7afb543c', 'phabricator-remarkup-css' => '7afb543c',
'phabricator-search-results-css' => '7dea472c', 'phabricator-search-results-css' => '7dea472c',
'phabricator-shaped-request' => '7cbe244b', 'phabricator-shaped-request' => '7cbe244b',
'phabricator-side-menu-view-css' => 'bec2458e', 'phabricator-side-menu-view-css' => '91b7a42c',
'phabricator-slowvote-css' => 'da0afb1b', 'phabricator-slowvote-css' => 'da0afb1b',
'phabricator-source-code-view-css' => 'cbeef983', 'phabricator-source-code-view-css' => 'cbeef983',
'phabricator-standard-page-view' => '3c99cdf4', 'phabricator-standard-page-view' => '3c99cdf4',

View file

@ -2855,6 +2855,7 @@ phutil_register_library_map(array(
'PhabricatorProjectIconSet' => 'applications/project/icon/PhabricatorProjectIconSet.php', 'PhabricatorProjectIconSet' => 'applications/project/icon/PhabricatorProjectIconSet.php',
'PhabricatorProjectInterface' => 'applications/project/interface/PhabricatorProjectInterface.php', 'PhabricatorProjectInterface' => 'applications/project/interface/PhabricatorProjectInterface.php',
'PhabricatorProjectListController' => 'applications/project/controller/PhabricatorProjectListController.php', 'PhabricatorProjectListController' => 'applications/project/controller/PhabricatorProjectListController.php',
'PhabricatorProjectListView' => 'applications/project/view/PhabricatorProjectListView.php',
'PhabricatorProjectLockController' => 'applications/project/controller/PhabricatorProjectLockController.php', 'PhabricatorProjectLockController' => 'applications/project/controller/PhabricatorProjectLockController.php',
'PhabricatorProjectLogicalAndDatasource' => 'applications/project/typeahead/PhabricatorProjectLogicalAndDatasource.php', 'PhabricatorProjectLogicalAndDatasource' => 'applications/project/typeahead/PhabricatorProjectLogicalAndDatasource.php',
'PhabricatorProjectLogicalDatasource' => 'applications/project/typeahead/PhabricatorProjectLogicalDatasource.php', 'PhabricatorProjectLogicalDatasource' => 'applications/project/typeahead/PhabricatorProjectLogicalDatasource.php',
@ -7206,6 +7207,7 @@ phutil_register_library_map(array(
'PhabricatorProjectHeraldAction' => 'HeraldAction', 'PhabricatorProjectHeraldAction' => 'HeraldAction',
'PhabricatorProjectIconSet' => 'PhabricatorIconSet', 'PhabricatorProjectIconSet' => 'PhabricatorIconSet',
'PhabricatorProjectListController' => 'PhabricatorProjectController', 'PhabricatorProjectListController' => 'PhabricatorProjectController',
'PhabricatorProjectListView' => 'AphrontView',
'PhabricatorProjectLockController' => 'PhabricatorProjectController', 'PhabricatorProjectLockController' => 'PhabricatorProjectController',
'PhabricatorProjectLogicalAndDatasource' => 'PhabricatorTypeaheadCompositeDatasource', 'PhabricatorProjectLogicalAndDatasource' => 'PhabricatorTypeaheadCompositeDatasource',
'PhabricatorProjectLogicalDatasource' => 'PhabricatorTypeaheadCompositeDatasource', 'PhabricatorProjectLogicalDatasource' => 'PhabricatorTypeaheadCompositeDatasource',

View file

@ -748,15 +748,15 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase {
->setNewValue($name); ->setNewValue($name);
if ($parent) { if ($parent) {
if ($is_milestone) {
$xactions[] = id(new PhabricatorProjectTransaction())
->setTransactionType(PhabricatorProjectTransaction::TYPE_MILESTONE)
->setNewValue($parent->getPHID());
} else {
$xactions[] = id(new PhabricatorProjectTransaction()) $xactions[] = id(new PhabricatorProjectTransaction())
->setTransactionType(PhabricatorProjectTransaction::TYPE_PARENT) ->setTransactionType(PhabricatorProjectTransaction::TYPE_PARENT)
->setNewValue($parent->getPHID()); ->setNewValue($parent->getPHID());
} }
if ($is_milestone) {
$xactions[] = id(new PhabricatorProjectTransaction())
->setTransactionType(PhabricatorProjectTransaction::TYPE_MILESTONE)
->setNewValue(true);
} }
$this->applyTransactions($project, $user, $xactions); $this->applyTransactions($project, $user, $xactions);

View file

@ -99,7 +99,6 @@ abstract class PhabricatorProjectController extends PhabricatorController {
$nav->addFilter("board/{$id}/", pht('Workboard')); $nav->addFilter("board/{$id}/", pht('Workboard'));
$nav->addFilter("members/{$id}/", pht('Members')); $nav->addFilter("members/{$id}/", pht('Members'));
$nav->addFilter("feed/{$id}/", pht('Feed')); $nav->addFilter("feed/{$id}/", pht('Feed'));
$nav->addFilter("details/{$id}/", pht('Edit Details'));
} }
$nav->addFilter('create', pht('Create Project')); $nav->addFilter('create', pht('Create Project'));
} }
@ -149,11 +148,29 @@ abstract class PhabricatorProjectController extends PhabricatorController {
$nav->addIcon("feed/{$id}/", pht('Feed'), 'fa-newspaper-o'); $nav->addIcon("feed/{$id}/", pht('Feed'), 'fa-newspaper-o');
$nav->addIcon("members/{$id}/", pht('Members'), 'fa-group'); $nav->addIcon("members/{$id}/", pht('Members'), 'fa-group');
$nav->addIcon("details/{$id}/", pht('Edit Details'), 'fa-pencil');
if (PhabricatorEnv::getEnvConfig('phabricator.show-prototypes')) { if (PhabricatorEnv::getEnvConfig('phabricator.show-prototypes')) {
$nav->addIcon("subprojects/{$id}/", pht('Subprojects'), 'fa-sitemap'); if ($project->supportsSubprojects()) {
$nav->addIcon("milestones/{$id}/", pht('Milestones'), 'fa-map-marker'); $subprojects_icon = 'fa-sitemap';
} else {
$subprojects_icon = 'fa-sitemap grey';
}
if ($project->supportsMilestones()) {
$milestones_icon = 'fa-map-marker';
} else {
$milestones_icon = 'fa-map-marker grey';
}
$nav->addIcon(
"subprojects/{$id}/",
pht('Subprojects'),
$subprojects_icon);
$nav->addIcon(
"milestones/{$id}/",
pht('Milestones'),
$milestones_icon);
} }
@ -170,8 +187,8 @@ abstract class PhabricatorProjectController extends PhabricatorController {
$ancestors[] = $project; $ancestors[] = $project;
foreach ($ancestors as $ancestor) { foreach ($ancestors as $ancestor) {
$crumbs->addTextCrumb( $crumbs->addTextCrumb(
$project->getName(), $ancestor->getName(),
$project->getURI()); $ancestor->getURI());
} }
} }

View file

@ -3,10 +3,111 @@
final class PhabricatorProjectEditController final class PhabricatorProjectEditController
extends PhabricatorProjectController { extends PhabricatorProjectController {
private $engine;
public function setEngine(PhabricatorProjectEditEngine $engine) {
$this->engine = $engine;
return $this;
}
public function getEngine() {
return $this->engine;
}
public function handleRequest(AphrontRequest $request) { public function handleRequest(AphrontRequest $request) {
return id(new PhabricatorProjectEditEngine()) $viewer = $this->getViewer();
->setController($this)
->buildResponse(); $engine = id(new PhabricatorProjectEditEngine())
->setController($this);
$this->setEngine($engine);
$id = $request->getURIData('id');
if (!$id) {
$parent_id = head($request->getArr('parent'));
if (!$parent_id) {
$parent_id = $request->getStr('parent');
}
if ($parent_id) {
$is_milestone = false;
} else {
$parent_id = head($request->getArr('milestone'));
if (!$parent_id) {
$parent_id = $request->getStr('milestone');
}
$is_milestone = true;
}
if ($parent_id) {
$query = id(new PhabricatorProjectQuery())
->setViewer($viewer)
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
));
if (ctype_digit($parent_id)) {
$query->withIDs(array($parent_id));
} else {
$query->withPHIDs(array($parent_id));
}
$parent = $query->executeOne();
if ($is_milestone) {
if (!$parent->supportsMilestones()) {
$cancel_uri = "/project/milestones/{$parent_id}/";
return $this->newDialog()
->setTitle(pht('No Milestones'))
->appendParagraph(
pht('You can not add milestones to this project.'))
->addCancelButton($cancel_uri);
}
$engine->setMilestoneProject($parent);
} else {
if (!$parent->supportsSubprojects()) {
$cancel_uri = "/project/subprojects/{$parent_id}/";
return $this->newDialog()
->setTitle(pht('No Subprojects'))
->appendParagraph(
pht('You can not add subprojects to this project.'))
->addCancelButton($cancel_uri);
}
$engine->setParentProject($parent);
}
$this->setProject($parent);
}
}
return $engine->buildResponse();
}
protected function buildApplicationCrumbs() {
$crumbs = parent::buildApplicationCrumbs();
$engine = $this->getEngine();
if ($engine) {
$parent = $engine->getParentProject();
if ($parent) {
$id = $parent->getID();
$crumbs->addTextCrumb(
pht('Subprojects'),
$this->getApplicationURI("subprojects/{$id}/"));
}
$milestone = $engine->getMilestoneProject();
if ($milestone) {
$id = $milestone->getID();
$crumbs->addTextCrumb(
pht('Milestones'),
$this->getApplicationURI("milestones/{$id}/"));
}
}
return $crumbs;
} }
} }

View file

@ -68,9 +68,11 @@ final class PhabricatorProjectMembersEditController
$project, $project,
PhabricatorPolicyCapability::CAN_EDIT); PhabricatorPolicyCapability::CAN_EDIT);
$supports_edit = $project->supportsEditMembers();
$form_box = null; $form_box = null;
$title = pht('Add Members'); $title = pht('Add Members');
if ($can_edit) { if ($can_edit && $supports_edit) {
$header_name = pht('Edit Members'); $header_name = pht('Edit Members');
$view_uri = $this->getApplicationURI('profile/'.$project->getID().'/'); $view_uri = $this->getApplicationURI('profile/'.$project->getID().'/');

View file

@ -18,6 +18,64 @@ final class PhabricatorProjectMilestonesController
$project = $this->getProject(); $project = $this->getProject();
$id = $project->getID(); $id = $project->getID();
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$project,
PhabricatorPolicyCapability::CAN_EDIT);
$has_support = $project->supportsMilestones();
if ($has_support) {
$milestones = id(new PhabricatorProjectQuery())
->setViewer($viewer)
->withParentProjectPHIDs(array($project->getPHID()))
->needImages(true)
->withIsMilestone(true)
->setOrder('newest')
->execute();
} else {
$milestones = array();
}
$can_create = $can_edit && $has_support;
if ($project->getHasMilestones()) {
$button_text = pht('Create Next Milestone');
} else {
$button_text = pht('Add Milestones');
}
$header = id(new PHUIHeaderView())
->setHeader(pht('Milestones'))
->addActionLink(
id(new PHUIButtonView())
->setTag('a')
->setHref("/project/edit/?milestone={$id}")
->setIconFont('fa-plus')
->setDisabled(!$can_create)
->setWorkflow(!$can_create)
->setText($button_text));
$box = id(new PHUIObjectBoxView())
->setHeader($header);
if (!$has_support) {
$no_support = pht(
'This project is a milestone. Milestones can not have their own '.
'milestones.');
$info_view = id(new PHUIInfoView())
->setErrors(array($no_support))
->setSeverity(PHUIInfoView::SEVERITY_WARNING);
$box->setInfoView($info_view);
}
$box->setObjectList(
id(new PhabricatorProjectListView())
->setUser($viewer)
->setProjects($milestones)
->renderList());
$nav = $this->buildIconNavView($project); $nav = $this->buildIconNavView($project);
$nav->selectFilter("milestones/{$id}/"); $nav->selectFilter("milestones/{$id}/");
@ -27,7 +85,8 @@ final class PhabricatorProjectMilestonesController
return $this->newPage() return $this->newPage()
->setNavigation($nav) ->setNavigation($nav)
->setCrumbs($crumbs) ->setCrumbs($crumbs)
->setTitle(array($project->getName(), pht('Milestones'))); ->setTitle(array($project->getName(), pht('Milestones')))
->appendChild($box);
} }
} }

View file

@ -18,6 +18,63 @@ final class PhabricatorProjectSubprojectsController
$project = $this->getProject(); $project = $this->getProject();
$id = $project->getID(); $id = $project->getID();
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$project,
PhabricatorPolicyCapability::CAN_EDIT);
$has_support = $project->supportsSubprojects();
if ($has_support) {
$subprojects = id(new PhabricatorProjectQuery())
->setViewer($viewer)
->withParentProjectPHIDs(array($project->getPHID()))
->needImages(true)
->withIsMilestone(false)
->execute();
} else {
$subprojects = array();
}
$can_create = $can_edit && $has_support;
if ($project->getHasSubprojects()) {
$button_text = pht('Create Subproject');
} else {
$button_text = pht('Add Subprojects');
}
$header = id(new PHUIHeaderView())
->setHeader(pht('Subprojects'))
->addActionLink(
id(new PHUIButtonView())
->setTag('a')
->setHref("/project/edit/?parent={$id}")
->setIconFont('fa-plus')
->setDisabled(!$can_create)
->setWorkflow(!$can_create)
->setText($button_text));
$box = id(new PHUIObjectBoxView())
->setHeader($header);
if (!$has_support) {
$no_support = pht(
'This project is a milestone. Milestones can not have subprojects.');
$info_view = id(new PHUIInfoView())
->setErrors(array($no_support))
->setSeverity(PHUIInfoView::SEVERITY_WARNING);
$box->setInfoView($info_view);
}
$box->setObjectList(
id(new PhabricatorProjectListView())
->setUser($viewer)
->setProjects($subprojects)
->renderList());
$nav = $this->buildIconNavView($project); $nav = $this->buildIconNavView($project);
$nav->selectFilter("subprojects/{$id}/"); $nav->selectFilter("subprojects/{$id}/");
@ -27,7 +84,8 @@ final class PhabricatorProjectSubprojectsController
return $this->newPage() return $this->newPage()
->setNavigation($nav) ->setNavigation($nav)
->setCrumbs($crumbs) ->setCrumbs($crumbs)
->setTitle(array($project->getName(), pht('Subprojects'))); ->setTitle(array($project->getName(), pht('Subprojects')))
->appendChild($box);
} }
} }

View file

@ -74,23 +74,10 @@ final class PhabricatorProjectTransactionEditor
case PhabricatorProjectTransaction::TYPE_COLOR: case PhabricatorProjectTransaction::TYPE_COLOR:
case PhabricatorProjectTransaction::TYPE_LOCKED: case PhabricatorProjectTransaction::TYPE_LOCKED:
case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_PARENT:
case PhabricatorProjectTransaction::TYPE_MILESTONE:
return $xaction->getNewValue(); return $xaction->getNewValue();
case PhabricatorProjectTransaction::TYPE_SLUGS: case PhabricatorProjectTransaction::TYPE_SLUGS:
return $this->normalizeSlugs($xaction->getNewValue()); return $this->normalizeSlugs($xaction->getNewValue());
case PhabricatorProjectTransaction::TYPE_MILESTONE:
$current = queryfx_one(
$object->establishConnection('w'),
'SELECT MAX(milestoneNumber) n
FROM %T
WHERE parentProjectPHID = %s',
$object->getTableName(),
$object->getParentProject()->getPHID());
if (!$current) {
$number = 1;
} else {
$number = (int)$current['n'] + 1;
}
return $number;
} }
return parent::getCustomTransactionNewValue($object, $xaction); return parent::getCustomTransactionNewValue($object, $xaction);
@ -127,7 +114,21 @@ final class PhabricatorProjectTransactionEditor
$object->setParentProjectPHID($xaction->getNewValue()); $object->setParentProjectPHID($xaction->getNewValue());
return; return;
case PhabricatorProjectTransaction::TYPE_MILESTONE: case PhabricatorProjectTransaction::TYPE_MILESTONE:
$object->setMilestoneNumber($xaction->getNewValue()); $current = queryfx_one(
$object->establishConnection('w'),
'SELECT MAX(milestoneNumber) n
FROM %T
WHERE parentProjectPHID = %s',
$object->getTableName(),
$object->getParentProject()->getPHID());
if (!$current) {
$number = 1;
} else {
$number = (int)$current['n'] + 1;
}
$object->setMilestoneNumber($number);
$object->setParentProjectPHID($xaction->getNewValue());
return; return;
} }
@ -239,6 +240,84 @@ final class PhabricatorProjectTransactionEditor
return parent::applyBuiltinExternalTransaction($object, $xaction); return parent::applyBuiltinExternalTransaction($object, $xaction);
} }
protected function validateAllTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$errors = array();
// Prevent creating projects which are both subprojects and milestones,
// since this does not make sense, won't work, and will break everything.
$parent_xaction = null;
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorProjectTransaction::TYPE_PARENT:
case PhabricatorProjectTransaction::TYPE_MILESTONE:
if ($xaction->getNewValue() === null) {
continue;
}
if (!$parent_xaction) {
$parent_xaction = $xaction;
continue;
}
$errors[] = new PhabricatorApplicationTransactionValidationError(
$xaction->getTransactionType(),
pht('Invalid'),
pht(
'When creating a project, specify a maximum of one parent '.
'project or milestone project. A project can not be both a '.
'subproject and a milestone.'),
$xaction);
break;
break;
}
}
$is_milestone = $object->isMilestone();
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorProjectTransaction::TYPE_MILESTONE:
if ($xaction->getNewValue() !== null) {
$is_milestone = true;
}
break;
}
}
$is_parent = $object->getHasSubprojects();
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorProjectTransaction::TYPE_MEMBERS:
if ($is_parent) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$xaction->getTransactionType(),
pht('Invalid'),
pht(
'You can not change members of a project with subprojects '.
'directly. Members of any subproject are automatically '.
'members of the parent project.'),
$xaction);
}
if ($is_milestone) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$xaction->getTransactionType(),
pht('Invalid'),
pht(
'You can not change members of a milestone. Members of the '.
'parent project are automatically members of the milestone.'),
$xaction);
}
break;
}
}
return $errors;
}
protected function validateTransaction( protected function validateTransaction(
PhabricatorLiskDAO $object, PhabricatorLiskDAO $object,
$type, $type,
@ -367,25 +446,29 @@ final class PhabricatorProjectTransactionEditor
break; break;
case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_PARENT:
case PhabricatorProjectTransaction::TYPE_MILESTONE:
if (!$xactions) { if (!$xactions) {
break; break;
} }
$xaction = last($xactions); $xaction = last($xactions);
$parent_phid = $xaction->getNewValue();
if (!$parent_phid) {
continue;
}
if (!$this->getIsNewObject()) { if (!$this->getIsNewObject()) {
$errors[] = new PhabricatorApplicationTransactionValidationError( $errors[] = new PhabricatorApplicationTransactionValidationError(
$type, $type,
pht('Invalid'), pht('Invalid'),
pht( pht(
'You can only set a parent project when creating a project '. 'You can only set a parent or milestone project when creating a '.
'for the first time.'), 'project for the first time.'),
$xaction); $xaction);
break; break;
} }
$parent_phid = $xaction->getNewValue();
$projects = id(new PhabricatorProjectQuery()) $projects = id(new PhabricatorProjectQuery())
->setViewer($this->requireActor()) ->setViewer($this->requireActor())
->withPHIDs(array($parent_phid)) ->withPHIDs(array($parent_phid))
@ -400,8 +483,8 @@ final class PhabricatorProjectTransactionEditor
$type, $type,
pht('Invalid'), pht('Invalid'),
pht( pht(
'Parent project PHID ("%s") must be the PHID of a valid, '. 'Parent or milestone project PHID ("%s") must be the PHID of a '.
'visible project which you have permission to edit.', 'valid, visible project which you have permission to edit.',
$parent_phid), $parent_phid),
$xaction); $xaction);
break; break;
@ -414,8 +497,8 @@ final class PhabricatorProjectTransactionEditor
$type, $type,
pht('Invalid'), pht('Invalid'),
pht( pht(
'Parent project PHID ("%s") must not be a milestone. '. 'Parent or milestone project PHID ("%s") must not be a '.
'Milestones may not have subprojects.', 'milestone. Milestones may not have subprojects or milestones.',
$parent_phid), $parent_phid),
$xaction); $xaction);
break; break;
@ -427,9 +510,9 @@ final class PhabricatorProjectTransactionEditor
$type, $type,
pht('Invalid'), pht('Invalid'),
pht( pht(
'You can not create a subproject under this parent because '. 'You can not create a subproject or mielstone under this parent '.
'it would nest projects too deeply. The maximum nesting '. 'because it would nest projects too deeply. The maximum '.
'depth of projects is %s.', 'nesting depth of projects is %s.',
new PhutilNumber($limit)), new PhutilNumber($limit)),
$xaction); $xaction);
break; break;
@ -611,6 +694,7 @@ final class PhabricatorProjectTransactionEditor
array $xactions) { array $xactions) {
$materialize = false; $materialize = false;
$new_parent = null;
foreach ($xactions as $xaction) { foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) { switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_EDGE: case PhabricatorTransactions::TYPE_EDGE:
@ -622,10 +706,34 @@ final class PhabricatorProjectTransactionEditor
break; break;
case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_PARENT:
$materialize = true; $materialize = true;
$new_parent = $object->getParentProject();
break; break;
} }
} }
if ($new_parent) {
// If we just created the first subproject of this parent, we want to
// copy all of the real members to the subproject.
if (!$new_parent->getHasSubprojects()) {
$member_type = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST;
$project_members = PhabricatorEdgeQuery::loadDestinationPHIDs(
$new_parent->getPHID(),
$member_type);
if ($project_members) {
$editor = id(new PhabricatorEdgeEditor());
foreach ($project_members as $phid) {
$editor->addEdge($object->getPHID(), $member_type, $phid);
}
$editor->save();
}
}
}
// TODO: We should dump an informational transaction onto the parent
// project to show that we created the sub-thing.
if ($materialize) { if ($materialize) {
id(new PhabricatorProjectsMembershipIndexEngineExtension()) id(new PhabricatorProjectsMembershipIndexEngineExtension())
->rematerialize($object); ->rematerialize($object);

View file

@ -5,6 +5,27 @@ final class PhabricatorProjectEditEngine
const ENGINECONST = 'projects.project'; const ENGINECONST = 'projects.project';
private $parentProject;
private $milestoneProject;
public function setParentProject(PhabricatorProject $parent_project) {
$this->parentProject = $parent_project;
return $this;
}
public function getParentProject() {
return $this->parentProject;
}
public function setMilestoneProject(PhabricatorProject $milestone_project) {
$this->milestoneProject = $milestone_project;
return $this;
}
public function getMilestoneProject() {
return $this->milestoneProject;
}
public function getEngineName() { public function getEngineName() {
return pht('Projects'); return pht('Projects');
} }
@ -50,6 +71,22 @@ final class PhabricatorProjectEditEngine
return $object->getURI(); return $object->getURI();
} }
protected function getObjectCreateCancelURI($object) {
$parent = $this->getParentProject();
if ($parent) {
$id = $parent->getID();
return "/project/subprojects/{$id}/";
}
$milestone = $this->getMilestoneProject();
if ($milestone) {
$id = $milestone->getID();
return "/project/milestones/{$id}/";
}
return parent::getObjectCreateCancelURI($object);
}
protected function getCreateNewObjectPolicy() { protected function getCreateNewObjectPolicy() {
return $this->getApplication()->getPolicy( return $this->getApplication()->getPolicy(
ProjectCreateProjectsCapability::CAPABILITY); ProjectCreateProjectsCapability::CAPABILITY);
@ -65,6 +102,8 @@ final class PhabricatorProjectEditEngine
$configuration $configuration
->setFieldOrder( ->setFieldOrder(
array( array(
'parent',
'milestone',
'name', 'name',
'std:project:internal:description', 'std:project:internal:description',
'icon', 'icon',
@ -84,7 +123,52 @@ final class PhabricatorProjectEditEngine
unset($slugs[$object->getPrimarySlug()]); unset($slugs[$object->getPrimarySlug()]);
$slugs = array_values($slugs); $slugs = array_values($slugs);
$milestone = $this->getMilestoneProject();
$parent = $this->getParentProject();
if ($parent) {
$parent_phid = $parent->getPHID();
} else {
$parent_phid = null;
}
if ($milestone) {
$milestone_phid = $milestone->getPHID();
} else {
$milestone_phid = null;
}
return array( return array(
id(new PhabricatorHandlesEditField())
->setKey('parent')
->setLabel(pht('Parent'))
->setDescription(pht('Create a subproject of an existing project.'))
->setConduitDescription(
pht('Choose a parent project to create a subproject beneath.'))
->setConduitTypeDescription(pht('PHID of the parent project.'))
->setAliases(array('parentPHID'))
->setTransactionType(PhabricatorProjectTransaction::TYPE_PARENT)
->setHandleParameterType(new AphrontPHIDHTTPParameterType())
->setSingleValue($parent_phid)
->setIsReorderable(false)
->setIsDefaultable(false)
->setIsLockable(false)
->setIsLocked(true),
id(new PhabricatorHandlesEditField())
->setKey('milestone')
->setLabel(pht('Milestone Of'))
->setDescription(pht('Parent project to create a milestone for.'))
->setConduitDescription(
pht('Choose a parent project to create a new milestone for.'))
->setConduitTypeDescription(pht('PHID of the parent project.'))
->setAliases(array('milestonePHID'))
->setTransactionType(PhabricatorProjectTransaction::TYPE_MILESTONE)
->setHandleParameterType(new AphrontPHIDHTTPParameterType())
->setSingleValue($milestone_phid)
->setIsReorderable(false)
->setIsDefaultable(false)
->setIsLockable(false)
->setIsLocked(true),
id(new PhabricatorTextEditField()) id(new PhabricatorTextEditField())
->setKey('name') ->setKey('name')
->setLabel(pht('Name')) ->setLabel(pht('Name'))

View file

@ -164,42 +164,15 @@ protected function buildQueryFromParameters(array $map) {
array $handles) { array $handles) {
assert_instances_of($projects, 'PhabricatorProject'); assert_instances_of($projects, 'PhabricatorProject');
$viewer = $this->requireViewer(); $viewer = $this->requireViewer();
$handles = $viewer->loadHandles(mpull($projects, 'getPHID'));
$list = new PHUIObjectItemListView(); $list = id(new PhabricatorProjectListView())
$list->setUser($viewer); ->setUser($viewer)
$can_edit_projects = id(new PhabricatorPolicyFilter()) ->setProjects($projects)
->setViewer($viewer) ->renderList();
->requireCapabilities(array(PhabricatorPolicyCapability::CAN_EDIT))
->apply($projects);
foreach ($projects as $key => $project) {
$id = $project->getID();
$tag_list = id(new PHUIHandleTagListView())
->setSlim(true)
->setHandles(array($handles[$project->getPHID()]));
$item = id(new PHUIObjectItemView())
->setHeader($project->getName())
->setHref($this->getApplicationURI("view/{$id}/"))
->setImageURI($project->getProfileImageURI())
->addAttribute($tag_list);
if ($project->getStatus() == PhabricatorProjectStatus::STATUS_ARCHIVED) {
$item->addIcon('delete-grey', pht('Archived'));
$item->setDisabled(true);
}
$list->addItem($item);
}
$result = new PhabricatorApplicationSearchResultView();
$result->setObjectList($list);
$result->setNoDataString(pht('No projects found.'));
return $result;
return id(new PhabricatorApplicationSearchResultView())
->setObjectList($list)
->setNoDataString(pht('No projects found.'));
} }
protected function getNewUserBody() { protected function getNewUserBody() {

View file

@ -88,6 +88,10 @@ final class PhabricatorProject extends PhabricatorProjectDAO
} }
public function getPolicy($capability) { public function getPolicy($capability) {
if ($this->isMilestone()) {
return $this->getParentProject()->getPolicy($capability);
}
switch ($capability) { switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW: case PhabricatorPolicyCapability::CAN_VIEW:
return $this->getViewPolicy(); return $this->getViewPolicy();
@ -99,6 +103,12 @@ final class PhabricatorProject extends PhabricatorProjectDAO
} }
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
if ($this->isMilestone()) {
return $this->getParentProject()->hasAutomaticCapability(
$capability,
$viewer);
}
$can_edit = PhabricatorPolicyCapability::CAN_EDIT; $can_edit = PhabricatorPolicyCapability::CAN_EDIT;
switch ($capability) { switch ($capability) {
@ -437,6 +447,34 @@ final class PhabricatorProject extends PhabricatorProjectDAO
return $ancestors; return $ancestors;
} }
public function supportsEditMembers() {
if ($this->isMilestone()) {
return false;
}
if ($this->getHasSubprojects()) {
return false;
}
return true;
}
public function supportsMilestones() {
if ($this->isMilestone()) {
return false;
}
return true;
}
public function supportsSubprojects() {
if ($this->isMilestone()) {
return false;
}
return true;
}
/* -( PhabricatorSubscribableInterface )----------------------------------- */ /* -( PhabricatorSubscribableInterface )----------------------------------- */

View file

@ -0,0 +1,53 @@
<?php
final class PhabricatorProjectListView extends AphrontView {
private $projects;
public function setProjects(array $projects) {
$this->projects = $projects;
return $this;
}
public function getProjects() {
return $this->projects;
}
public function renderList() {
$viewer = $this->getUser();
$projects = $this->getProjects();
$handles = $viewer->loadHandles(mpull($projects, 'getPHID'));
$list = id(new PHUIObjectItemListView())
->setUser($viewer);
foreach ($projects as $key => $project) {
$id = $project->getID();
$tag_list = id(new PHUIHandleTagListView())
->setSlim(true)
->setHandles(array($handles[$project->getPHID()]));
$item = id(new PHUIObjectItemView())
->setHeader($project->getName())
->setHref("/project/view/{$id}/")
->setImageURI($project->getProfileImageURI())
->addAttribute($tag_list);
if ($project->getStatus() == PhabricatorProjectStatus::STATUS_ARCHIVED) {
$item->addIcon('delete-grey', pht('Archived'));
$item->setDisabled(true);
}
$list->addItem($item);
}
return $list;
}
public function render() {
return $this->renderList();
}
}

View file

@ -89,6 +89,10 @@
color: {$blue}; color: {$blue};
} }
.phabricator-icon-nav .phabricator-side-menu .phui-list-item-icon.grey {
color: {$lightgreyborder};
}
.phabricator-icon-nav .phabricator-side-menu .phui-list-item-selected { .phabricator-icon-nav .phabricator-side-menu .phui-list-item-selected {
border: none; border: none;
} }