1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-27 07:50:57 +01:00

Allow Spaces to be archived

Summary:
Ref T8377. This adds a standard disable/enable feature to Spaces, with a couple of twists:

  - You can't create new stuff in an archived space, and you can't move stuff into an archived space.
  - We don't show results from an archived space by default in ApplicationSearch queries. You can still find these objects if you explicitly search for "Spaces: <the archived space>".

So this is a "put it in a box in the attic" sort of operation, but that seems fairly nice/reasonable.

Test Plan:
  - Archived and activated spaces.
  - Used ApplicationSearch, which omitted archived objects by default but allowed searches for them, specifically, to succeed.
  - Tried to create objects into an archived space (this is not allowed).
  - Edited objects in an archived space (this is OK).

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T8377

Differential Revision: https://secure.phabricator.com/D13238
This commit is contained in:
epriestley 2015-06-11 10:13:47 -07:00
parent a06618f879
commit 88e7cd158f
18 changed files with 309 additions and 38 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE {$NAMESPACE}_spaces.spaces_namespace
ADD isArchived BOOL NOT NULL;

View file

@ -2577,6 +2577,7 @@ phutil_register_library_map(array(
'PhabricatorSortTableUIExample' => 'applications/uiexample/examples/PhabricatorSortTableUIExample.php',
'PhabricatorSourceCodeView' => 'view/layout/PhabricatorSourceCodeView.php',
'PhabricatorSpacesApplication' => 'applications/spaces/application/PhabricatorSpacesApplication.php',
'PhabricatorSpacesArchiveController' => 'applications/spaces/controller/PhabricatorSpacesArchiveController.php',
'PhabricatorSpacesCapabilityCreateSpaces' => 'applications/spaces/capability/PhabricatorSpacesCapabilityCreateSpaces.php',
'PhabricatorSpacesCapabilityDefaultEdit' => 'applications/spaces/capability/PhabricatorSpacesCapabilityDefaultEdit.php',
'PhabricatorSpacesCapabilityDefaultView' => 'applications/spaces/capability/PhabricatorSpacesCapabilityDefaultView.php',
@ -6089,6 +6090,7 @@ phutil_register_library_map(array(
'PhabricatorSortTableUIExample' => 'PhabricatorUIExample',
'PhabricatorSourceCodeView' => 'AphrontView',
'PhabricatorSpacesApplication' => 'PhabricatorApplication',
'PhabricatorSpacesArchiveController' => 'PhabricatorSpacesController',
'PhabricatorSpacesCapabilityCreateSpaces' => 'PhabricatorPolicyCapability',
'PhabricatorSpacesCapabilityDefaultEdit' => 'PhabricatorPolicyCapability',
'PhabricatorSpacesCapabilityDefaultView' => 'PhabricatorPolicyCapability',

View file

@ -759,7 +759,7 @@ final class PhabricatorUser
// for now just use the global space if one exists.
// If the viewer has access to the default space, use that.
$spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($this);
$spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces($this);
foreach ($spaces as $space) {
if ($space->getIsDefaultNamespace()) {
return $space->getPHID();

View file

@ -149,6 +149,10 @@ abstract class PhabricatorApplicationSearchEngine extends Phobject {
if ($object instanceof PhabricatorSpacesInterface) {
if (!empty($map['spacePHIDs'])) {
$query->withSpacePHIDs($map['spacePHIDs']);
} else {
// If the user doesn't search for objects in specific spaces, we
// default to "all active spaces you have permission to view".
$query->withSpaceIsArchived(false);
}
}

View file

@ -38,6 +38,15 @@ final class PhabricatorSpacesApplication extends PhabricatorApplication {
return true;
}
public function getHelpDocumentationArticles(PhabricatorUser $viewer) {
return array(
array(
'name' => pht('Spaces User Guide'),
'href' => PhabricatorEnv::getDoclink('Spaces User Guide'),
),
);
}
public function getRemarkupRules() {
return array(
new PhabricatorSpacesRemarkupRule(),
@ -51,6 +60,8 @@ final class PhabricatorSpacesApplication extends PhabricatorApplication {
'(?:query/(?P<queryKey>[^/]+)/)?' => 'PhabricatorSpacesListController',
'create/' => 'PhabricatorSpacesEditController',
'edit/(?:(?P<id>\d+)/)?' => 'PhabricatorSpacesEditController',
'(?P<action>activate|archive)/(?P<id>\d+)/'
=> 'PhabricatorSpacesArchiveController',
),
);
}

View file

@ -0,0 +1,76 @@
<?php
final class PhabricatorSpacesArchiveController
extends PhabricatorSpacesController {
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getUser();
$space = id(new PhabricatorSpacesNamespaceQuery())
->setViewer($viewer)
->withIDs(array($request->getURIData('id')))
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->executeOne();
if (!$space) {
return new Aphront404Response();
}
$is_archive = ($request->getURIData('action') == 'archive');
$cancel_uri = '/'.$space->getMonogram();
if ($request->isFormPost()) {
$type_archive = PhabricatorSpacesNamespaceTransaction::TYPE_ARCHIVE;
$xactions = array();
$xactions[] = id(new PhabricatorSpacesNamespaceTransaction())
->setTransactionType($type_archive)
->setNewValue($is_archive ? 1 : 0);
$editor = id(new PhabricatorSpacesNamespaceEditor())
->setActor($viewer)
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->setContentSourceFromRequest($request);
$editor->applyTransactions($space, $xactions);
return id(new AphrontRedirectResponse())->setURI($cancel_uri);
}
$body = array();
if ($is_archive) {
$title = pht('Archive Space: %s', $space->getNamespaceName());
$body[] = pht(
'If you archive this Space, you will no longer be able to create '.
'new objects inside it.');
$body[] = pht(
'Existing objects in this Space will be hidden from query results '.
'by default.');
$button = pht('Archive Space');
} else {
$title = pht('Activate Space: %s', $space->getNamespaceName());
$body[] = pht(
'If you activate this space, you will be able to create objects '.
'inside it again.');
$body[] = pht(
'Existing objects will no longer be hidden from query results.');
$button = pht('Activate Space');
}
$dialog = $this->newDialog()
->setTitle($title)
->addCancelButton($cancel_uri)
->addSubmitButton($button);
foreach ($body as $paragraph) {
$dialog->appendParagraph($paragraph);
}
return $dialog;
}
}

View file

@ -37,6 +37,12 @@ final class PhabricatorSpacesViewController
->setHeader($space->getNamespaceName())
->setPolicyObject($space);
if ($space->getIsArchived()) {
$header->setStatus('fa-ban', 'red', pht('Archived'));
} else {
$header->setStatus('fa-check', 'bluegrey', pht('Active'));
}
$box = id(new PHUIObjectBoxView())
->setHeader($header)
->addPropertyList($property_list);
@ -112,6 +118,26 @@ final class PhabricatorSpacesViewController
->setWorkflow(!$can_edit)
->setDisabled(!$can_edit));
$id = $space->getID();
if ($space->getIsArchived()) {
$list->addAction(
id(new PhabricatorActionView())
->setName(pht('Activate Space'))
->setIcon('fa-check')
->setHref($this->getApplicationURI("activate/{$id}/"))
->setDisabled(!$can_edit)
->setWorkflow(true));
} else {
$list->addAction(
id(new PhabricatorActionView())
->setName(pht('Archive Space'))
->setIcon('fa-ban')
->setHref($this->getApplicationURI("archive/{$id}/"))
->setDisabled(!$can_edit)
->setWorkflow(true));
}
return $list;
}

View file

@ -17,6 +17,7 @@ final class PhabricatorSpacesNamespaceEditor
$types[] = PhabricatorSpacesNamespaceTransaction::TYPE_NAME;
$types[] = PhabricatorSpacesNamespaceTransaction::TYPE_DESCRIPTION;
$types[] = PhabricatorSpacesNamespaceTransaction::TYPE_DEFAULT;
$types[] = PhabricatorSpacesNamespaceTransaction::TYPE_ARCHIVE;
$types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
$types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
@ -40,6 +41,8 @@ final class PhabricatorSpacesNamespaceEditor
return null;
}
return $object->getDescription();
case PhabricatorSpacesNamespaceTransaction::TYPE_ARCHIVE:
return $object->getIsArchived();
case PhabricatorSpacesNamespaceTransaction::TYPE_DEFAULT:
return $object->getIsDefaultNamespace() ? 1 : null;
case PhabricatorTransactions::TYPE_VIEW_POLICY:
@ -61,6 +64,8 @@ final class PhabricatorSpacesNamespaceEditor
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
return $xaction->getNewValue();
case PhabricatorSpacesNamespaceTransaction::TYPE_ARCHIVE:
return $xaction->getNewValue() ? 1 : 0;
case PhabricatorSpacesNamespaceTransaction::TYPE_DEFAULT:
return $xaction->getNewValue() ? 1 : null;
}
@ -84,6 +89,9 @@ final class PhabricatorSpacesNamespaceEditor
case PhabricatorSpacesNamespaceTransaction::TYPE_DEFAULT:
$object->setIsDefaultNamespace($new ? 1 : null);
return;
case PhabricatorSpacesNamespaceTransaction::TYPE_ARCHIVE:
$object->setIsArchived($new ? 1 : 0);
return;
case PhabricatorTransactions::TYPE_VIEW_POLICY:
$object->setViewPolicy($new);
return;
@ -103,6 +111,7 @@ final class PhabricatorSpacesNamespaceEditor
case PhabricatorSpacesNamespaceTransaction::TYPE_NAME:
case PhabricatorSpacesNamespaceTransaction::TYPE_DESCRIPTION:
case PhabricatorSpacesNamespaceTransaction::TYPE_DEFAULT:
case PhabricatorSpacesNamespaceTransaction::TYPE_ARCHIVE:
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
return;
@ -128,13 +137,27 @@ final class PhabricatorSpacesNamespaceEditor
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Required'),
pht('Spaces must have a name.'),
pht('Spaces must have a name.'),
nonempty(last($xactions), null));
$error->setIsMissingFieldError(true);
$errors[] = $error;
}
break;
case PhabricatorSpacesNamespaceTransaction::TYPE_DEFAULT:
if (!$this->getIsNewObject()) {
foreach ($xactions as $xaction) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht(
'Only the first space created can be the default space, and '.
'it must remain the default space evermore.'),
$xaction);
}
}
break;
}
return $errors;

View file

@ -39,6 +39,10 @@ final class PhabricatorSpacesNamespacePHIDType
$handle->setName($name);
$handle->setFullName(pht('%s %s', $monogram, $name));
$handle->setURI('/'.$monogram);
if ($namespace->getIsArchived()) {
$handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED);
}
}
}

View file

@ -9,6 +9,7 @@ final class PhabricatorSpacesNamespaceQuery
private $ids;
private $phids;
private $isDefaultNamespace;
private $isArchived;
public function withIDs(array $ids) {
$this->ids = $ids;
@ -25,38 +26,32 @@ final class PhabricatorSpacesNamespaceQuery
return $this;
}
public function withIsArchived($archived) {
$this->isArchived = $archived;
return $this;
}
public function getQueryApplicationClass() {
return 'PhabricatorSpacesApplication';
}
protected function loadPage() {
$table = new PhabricatorSpacesNamespace();
$conn_r = $table->establishConnection('r');
$rows = queryfx_all(
$conn_r,
'SELECT * FROM %T %Q %Q %Q',
$table->getTableName(),
$this->buildWhereClause($conn_r),
$this->buildOrderClause($conn_r),
$this->buildLimitClause($conn_r));
return $table->loadAllFromArray($rows);
return $this->loadStandardPage(new PhabricatorSpacesNamespace());
}
protected function buildWhereClause(AphrontDatabaseConnection $conn_r) {
$where = array();
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->ids !== null) {
$where[] = qsprintf(
$conn_r,
$conn,
'id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn_r,
$conn,
'phid IN (%Ls)',
$this->phids);
}
@ -64,17 +59,23 @@ final class PhabricatorSpacesNamespaceQuery
if ($this->isDefaultNamespace !== null) {
if ($this->isDefaultNamespace) {
$where[] = qsprintf(
$conn_r,
$conn,
'isDefaultNamespace = 1');
} else {
$where[] = qsprintf(
$conn_r,
$conn,
'isDefaultNamespace IS NULL');
}
}
$where[] = $this->buildPagingClause($conn_r);
return $this->formatWhereClause($where);
if ($this->isArchived !== null) {
$where[] = qsprintf(
$conn,
'isArchived = %d',
(int)$this->isArchived);
}
return $where;
}
public static function destroySpacesCache() {
@ -156,6 +157,21 @@ final class PhabricatorSpacesNamespaceQuery
return $result;
}
public static function getViewerActiveSpaces(PhabricatorUser $viewer) {
$spaces = self::getViewerSpaces($viewer);
foreach ($spaces as $key => $space) {
if ($space->getIsArchived()) {
unset($spaces[$key]);
}
}
return $spaces;
}
/**
* Get the Space PHID for an object, if one exists.
*

View file

@ -11,28 +11,39 @@ final class PhabricatorSpacesNamespaceSearchEngine
return pht('Spaces');
}
public function buildSavedQueryFromRequest(AphrontRequest $request) {
$saved = new PhabricatorSavedQuery();
return $saved;
public function newQuery() {
return new PhabricatorSpacesNamespaceQuery();
}
public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) {
$query = id(new PhabricatorSpacesNamespaceQuery());
public function buildCustomSearchFields() {
return array(
id(new PhabricatorSearchThreeStateField())
->setLabel(pht('Active'))
->setKey('active')
->setOptions(
pht('(Show All)'),
pht('Show Only Active Spaces'),
pht('Hide Active Spaces')),
);
}
public function buildQueryFromParameters(array $map) {
$query = $this->newQuery();
if ($map['active']) {
$query->withIsArchived(!$map['active']);
}
return $query;
}
public function buildSearchForm(
AphrontFormView $form,
PhabricatorSavedQuery $saved_query) {}
protected function getURI($path) {
return '/spaces/'.$path;
}
protected function getBuiltinQueryNames() {
$names = array(
'active' => pht('Active Spaces'),
'all' => pht('All Spaces'),
);
@ -40,11 +51,12 @@ final class PhabricatorSpacesNamespaceSearchEngine
}
public function buildSavedQueryFromBuiltin($query_key) {
$query = $this->newSavedQuery();
$query->setQueryKey($query_key);
switch ($query_key) {
case 'active':
return $query->setParameter('active', true);
case 'all':
return $query;
}
@ -72,6 +84,10 @@ final class PhabricatorSpacesNamespaceSearchEngine
$item->addIcon('fa-certificate', pht('Default Space'));
}
if ($space->getIsArchived()) {
$item->setDisabled(true);
}
$list->addItem($item);
}

View file

@ -12,6 +12,7 @@ final class PhabricatorSpacesNamespace
protected $editPolicy;
protected $isDefaultNamespace;
protected $description;
protected $isArchived;
public static function initializeNewNamespace(PhabricatorUser $actor) {
$app = id(new PhabricatorApplicationQuery())
@ -28,7 +29,8 @@ final class PhabricatorSpacesNamespace
->setIsDefaultNamespace(null)
->setViewPolicy($view_policy)
->setEditPolicy($edit_policy)
->setDescription('');
->setDescription('')
->setIsArchived(0);
}
protected function getConfiguration() {
@ -38,6 +40,7 @@ final class PhabricatorSpacesNamespace
'namespaceName' => 'text255',
'isDefaultNamespace' => 'bool?',
'description' => 'text',
'isArchived' => 'bool',
),
self::CONFIG_KEY_SCHEMA => array(
'key_default' => array(

View file

@ -6,6 +6,7 @@ final class PhabricatorSpacesNamespaceTransaction
const TYPE_NAME = 'spaces:name';
const TYPE_DEFAULT = 'spaces:default';
const TYPE_DESCRIPTION = 'spaces:description';
const TYPE_ARCHIVE = 'spaces:archive';
public function getApplicationName() {
return 'spaces';
@ -78,6 +79,16 @@ final class PhabricatorSpacesNamespaceTransaction
return pht(
'%s made this the default space.',
$this->renderHandleLink($author_phid));
case self::TYPE_ARCHIVE:
if ($new) {
return pht(
'%s archived this space.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s activated this space.',
$this->renderHandleLink($author_phid));
}
}
return parent::getTitle();

View file

@ -21,9 +21,20 @@ final class PhabricatorSpacesNamespaceDatasource
$spaces = $this->executeQuery($query);
$results = array();
foreach ($spaces as $space) {
$results[] = id(new PhabricatorTypeaheadResult())
->setName($space->getNamespaceName())
$full_name = pht(
'%s %s',
$space->getMonogram(),
$space->getNamespaceName());
$result = id(new PhabricatorTypeaheadResult())
->setName($full_name)
->setPHID($space->getPHID());
if ($space->getIsArchived()) {
$result->setClosed(pht('Archived'));
}
$results[] = $result;
}
return $this->filterResultsAgainstTokens($results);

View file

@ -310,6 +310,7 @@ abstract class PhabricatorApplicationTransactionEditor
$space_phid = $default_space->getPHID();
}
}
return $space_phid;
case PhabricatorTransactions::TYPE_EDGE:
$edge_type = $xaction->getMetadataValue('edge:type');
@ -2011,6 +2012,8 @@ abstract class PhabricatorApplicationTransactionEditor
$has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor);
$actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor);
$active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces(
$actor);
foreach ($xactions as $xaction) {
$space_phid = $xaction->getNewValue();
@ -2040,6 +2043,23 @@ abstract class PhabricatorApplicationTransactionEditor
'You can not shift this object in the selected space, because '.
'the space does not exist or you do not have access to it.'),
$xaction);
} else if (empty($active_spaces[$space_phid])) {
// It's OK to edit objects in an archived space, so just move on if
// we aren't adjusting the value.
$old_space_phid = $this->getTransactionOldValue($object, $xaction);
if ($space_phid == $old_space_phid) {
continue;
}
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Archived'),
pht(
'You can not shift this object into the selected space, because '.
'the space is archived. Objects can not be created inside (or '.
'moved into) archived spaces.'),
$xaction);
}
}

View file

@ -0,0 +1,27 @@
@title Spaces User Guide
@group userguide
Guide to the Spaces application.
Overview
========
IMPORTANT: Spaces is a prototype application.
Archiving Spaces
================
If you no longer need a Space, you can archive it by choosing
{nav Archive Space} from the detail view. This hides the space and all the
objects in it without deleting any data.
New objects can't be created into archived spaces, and existing objects can't
be shifted into archived spaces. The UI won't give you options to choose
these spaces when creating or editing objects.
Additionally, objects (like tasks) in archived spaces won't be shown in most
search result lists by default. If you need to find objects in an archived
space, use the `Spaces` constraint to specifically search for objects in that
space.
You can reactivate a space later by choosing {nav Activate Space}.

View file

@ -25,6 +25,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery
private $edgeLogicConstraints = array();
private $edgeLogicConstraintsAreValid = false;
private $spacePHIDs;
private $spaceIsArchived;
protected function getPageCursors(array $page) {
return array(
@ -1722,6 +1723,11 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery
return $this;
}
public function withSpaceIsArchived($archived) {
$this->spaceIsArchived = $archived;
return $this;
}
/**
* Constrain the query to include only results in valid Spaces.
@ -1760,6 +1766,11 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery
$viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces(
$viewer);
foreach ($viewer_spaces as $viewer_space) {
if ($this->spaceIsArchived !== null) {
if ($viewer_space->getIsArchived() != $this->spaceIsArchived) {
continue;
}
}
$phid = $viewer_space->getPHID();
$space_phids[$phid] = $phid;
if ($viewer_space->getIsDefaultNamespace()) {

View file

@ -265,7 +265,7 @@ final class AphrontFormPolicyControl extends AphrontFormControl {
$select = AphrontFormSelectControl::renderSelectTag(
$space_phid,
$this->getSpaceOptions(),
$this->getSpaceOptions($space_phid),
array(
'name' => 'spacePHID',
));
@ -273,12 +273,20 @@ final class AphrontFormPolicyControl extends AphrontFormControl {
return $select;
}
protected function getSpaceOptions() {
protected function getSpaceOptions($space_phid) {
$viewer = $this->getUser();
$viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($viewer);
$map = array();
foreach ($viewer_spaces as $space) {
// Skip archived spaces, unless the object is already in that space.
if ($space->getIsArchived()) {
if ($space->getPHID() != $space_phid) {
continue;
}
}
$map[$space->getPHID()] = pht(
'Space %s: %s',
$space->getMonogram(),