diff --git a/resources/sql/autopatches/20140911.fund.1.initiative.sql b/resources/sql/autopatches/20140911.fund.1.initiative.sql new file mode 100644 index 0000000000..8e170dbcb0 --- /dev/null +++ b/resources/sql/autopatches/20140911.fund.1.initiative.sql @@ -0,0 +1,15 @@ +CREATE TABLE {$NAMESPACE}_fund.fund_initiative ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARCHAR(64) NOT NULL COLLATE utf8_bin, + name VARCHAR(255) NOT NULL, + ownerPHID VARCHAR(64) NOT NULL COLLATE utf8_bin, + description LONGTEXT NOT NULL, + viewPolicy VARCHAR(64) NOT NULL COLLATE utf8_bin, + editPolicy VARCHAR(64) NOT NULL COLLATE utf8_bin, + status VARCHAR(32) NOT NULL COLLATE utf8_bin, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_phid` (phid), + KEY `key_status` (status), + KEY `key_owner` (ownerPHID) +) ENGINE=InnoDB, COLLATE utf8_general_ci; diff --git a/resources/sql/autopatches/20140911.fund.2.xaction.sql b/resources/sql/autopatches/20140911.fund.2.xaction.sql new file mode 100644 index 0000000000..bfa3f9c3e2 --- /dev/null +++ b/resources/sql/autopatches/20140911.fund.2.xaction.sql @@ -0,0 +1,19 @@ +CREATE TABLE {$NAMESPACE}_fund.fund_initiativetransaction ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARCHAR(64) COLLATE utf8_bin NOT NULL, + authorPHID VARCHAR(64) COLLATE utf8_bin NOT NULL, + objectPHID VARCHAR(64) COLLATE utf8_bin NOT NULL, + viewPolicy VARCHAR(64) COLLATE utf8_bin NOT NULL, + editPolicy VARCHAR(64) COLLATE utf8_bin NOT NULL, + commentPHID VARCHAR(64) COLLATE utf8_bin DEFAULT NULL, + commentVersion INT UNSIGNED NOT NULL, + transactionType VARCHAR(32) COLLATE utf8_bin NOT NULL, + oldValue LONGTEXT COLLATE utf8_bin NOT NULL, + newValue LONGTEXT COLLATE utf8_bin NOT NULL, + contentSource LONGTEXT COLLATE utf8_bin NOT NULL, + metadata LONGTEXT COLLATE utf8_bin NOT NULL, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_phid` (`phid`), + KEY `key_object` (`objectPHID`) +) ENGINE=InnoDB, COLLATE utf8_general_ci; diff --git a/resources/sql/autopatches/20140911.fund.3.edge.sql b/resources/sql/autopatches/20140911.fund.3.edge.sql new file mode 100644 index 0000000000..0dc5821762 --- /dev/null +++ b/resources/sql/autopatches/20140911.fund.3.edge.sql @@ -0,0 +1,15 @@ +CREATE TABLE {$NAMESPACE}_fund.edge ( + src VARCHAR(64) NOT NULL COLLATE utf8_bin, + type VARCHAR(64) NOT NULL COLLATE utf8_bin, + dst VARCHAR(64) NOT NULL COLLATE utf8_bin, + dateCreated INT UNSIGNED NOT NULL, + seq INT UNSIGNED NOT NULL, + dataID INT UNSIGNED, + PRIMARY KEY (src, type, dst), + KEY (src, type, dateCreated, seq) +) ENGINE=InnoDB, COLLATE utf8_general_ci; + +CREATE TABLE {$NAMESPACE}_fund.edgedata ( + id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + data LONGTEXT NOT NULL COLLATE utf8_bin +) ENGINE=InnoDB, COLLATE utf8_general_ci; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 223d33207e..5be56d262c 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -654,6 +654,28 @@ phutil_register_library_map(array( 'FlagDeleteConduitAPIMethod' => 'applications/flag/conduit/FlagDeleteConduitAPIMethod.php', 'FlagEditConduitAPIMethod' => 'applications/flag/conduit/FlagEditConduitAPIMethod.php', 'FlagQueryConduitAPIMethod' => 'applications/flag/conduit/FlagQueryConduitAPIMethod.php', + 'FundBacking' => 'applications/fund/storage/FundBacking.php', + 'FundBackingEditor' => 'applications/fund/editor/FundBackingEditor.php', + 'FundBackingPHIDType' => 'applications/fund/phid/FundBackingPHIDType.php', + 'FundBackingQuery' => 'applications/fund/query/FundBackingQuery.php', + 'FundBackingTransaction' => 'applications/fund/storage/FundBackingTransaction.php', + 'FundBackingTransactionQuery' => 'applications/fund/query/FundBackingTransactionQuery.php', + 'FundController' => 'applications/fund/controller/FundController.php', + 'FundCreateInitiativesCapability' => 'applications/fund/capability/FundCreateInitiativesCapability.php', + 'FundDAO' => 'applications/fund/storage/FundDAO.php', + 'FundDefaultViewCapability' => 'applications/fund/capability/FundDefaultViewCapability.php', + 'FundInitiative' => 'applications/fund/storage/FundInitiative.php', + 'FundInitiativeCloseController' => 'applications/fund/controller/FundInitiativeCloseController.php', + 'FundInitiativeEditController' => 'applications/fund/controller/FundInitiativeEditController.php', + 'FundInitiativeEditor' => 'applications/fund/editor/FundInitiativeEditor.php', + 'FundInitiativeListController' => 'applications/fund/controller/FundInitiativeListController.php', + 'FundInitiativePHIDType' => 'applications/fund/phid/FundInitiativePHIDType.php', + 'FundInitiativeQuery' => 'applications/fund/query/FundInitiativeQuery.php', + 'FundInitiativeRemarkupRule' => 'applications/fund/remarkup/FundInitiativeRemarkupRule.php', + 'FundInitiativeSearchEngine' => 'applications/fund/query/FundInitiativeSearchEngine.php', + 'FundInitiativeTransaction' => 'applications/fund/storage/FundInitiativeTransaction.php', + 'FundInitiativeTransactionQuery' => 'applications/fund/query/FundInitiativeTransactionQuery.php', + 'FundInitiativeViewController' => 'applications/fund/controller/FundInitiativeViewController.php', 'HarbormasterBuild' => 'applications/harbormaster/storage/build/HarbormasterBuild.php', 'HarbormasterBuildAbortedException' => 'applications/harbormaster/exception/HarbormasterBuildAbortedException.php', 'HarbormasterBuildActionController' => 'applications/harbormaster/controller/HarbormasterBuildActionController.php', @@ -1600,6 +1622,7 @@ phutil_register_library_map(array( 'PhabricatorFlagsApplication' => 'applications/flag/application/PhabricatorFlagsApplication.php', 'PhabricatorFlagsUIEventListener' => 'applications/flag/events/PhabricatorFlagsUIEventListener.php', 'PhabricatorFormExample' => 'applications/uiexample/examples/PhabricatorFormExample.php', + 'PhabricatorFundApplication' => 'applications/fund/application/PhabricatorFundApplication.php', 'PhabricatorGarbageCollector' => 'infrastructure/daemon/garbagecollector/PhabricatorGarbageCollector.php', 'PhabricatorGarbageCollectorConfigOptions' => 'applications/config/option/PhabricatorGarbageCollectorConfigOptions.php', 'PhabricatorGarbageCollectorDaemon' => 'infrastructure/daemon/garbagecollector/PhabricatorGarbageCollectorDaemon.php', @@ -3422,6 +3445,42 @@ phutil_register_library_map(array( 'FlagDeleteConduitAPIMethod' => 'FlagConduitAPIMethod', 'FlagEditConduitAPIMethod' => 'FlagConduitAPIMethod', 'FlagQueryConduitAPIMethod' => 'FlagConduitAPIMethod', + 'FundBacking' => array( + 'FundDAO', + 'PhabricatorPolicyInterface', + 'PhabricatorApplicationTransactionInterface', + ), + 'FundBackingEditor' => 'PhabricatorApplicationTransactionEditor', + 'FundBackingPHIDType' => 'PhabricatorPHIDType', + 'FundBackingQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'FundBackingTransaction' => 'PhabricatorApplicationTransaction', + 'FundBackingTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'FundController' => 'PhabricatorController', + 'FundCreateInitiativesCapability' => 'PhabricatorPolicyCapability', + 'FundDAO' => 'PhabricatorLiskDAO', + 'FundDefaultViewCapability' => 'PhabricatorPolicyCapability', + 'FundInitiative' => array( + 'FundDAO', + 'PhabricatorPolicyInterface', + 'PhabricatorProjectInterface', + 'PhabricatorApplicationTransactionInterface', + 'PhabricatorSubscribableInterface', + 'PhabricatorMentionableInterface', + 'PhabricatorFlaggableInterface', + 'PhabricatorTokenReceiverInterface', + 'PhabricatorDestructibleInterface', + ), + 'FundInitiativeCloseController' => 'FundController', + 'FundInitiativeEditController' => 'FundController', + 'FundInitiativeEditor' => 'PhabricatorApplicationTransactionEditor', + 'FundInitiativeListController' => 'FundController', + 'FundInitiativePHIDType' => 'PhabricatorPHIDType', + 'FundInitiativeQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'FundInitiativeRemarkupRule' => 'PhabricatorObjectRemarkupRule', + 'FundInitiativeSearchEngine' => 'PhabricatorApplicationSearchEngine', + 'FundInitiativeTransaction' => 'PhabricatorApplicationTransaction', + 'FundInitiativeTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'FundInitiativeViewController' => 'FundController', 'HarbormasterBuild' => array( 'HarbormasterDAO', 'PhabricatorPolicyInterface', @@ -4465,6 +4524,7 @@ phutil_register_library_map(array( 'PhabricatorFlagsApplication' => 'PhabricatorApplication', 'PhabricatorFlagsUIEventListener' => 'PhabricatorEventListener', 'PhabricatorFormExample' => 'PhabricatorUIExample', + 'PhabricatorFundApplication' => 'PhabricatorApplication', 'PhabricatorGarbageCollector' => 'Phobject', 'PhabricatorGarbageCollectorConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorGarbageCollectorDaemon' => 'PhabricatorDaemon', diff --git a/src/applications/fund/application/PhabricatorFundApplication.php b/src/applications/fund/application/PhabricatorFundApplication.php new file mode 100644 index 0000000000..38da9b812a --- /dev/null +++ b/src/applications/fund/application/PhabricatorFundApplication.php @@ -0,0 +1,62 @@ +[1-9]\d*)' => 'FundInitiativeViewController', + '/fund/' => array( + '(?:query/(?P[^/]+)/)?' => 'FundInitiativeListController', + 'create/' => 'FundInitiativeEditController', + 'edit/(?:(?P[^/]+)/)?' => 'FundInitiativeEditController', + 'close/(?P[^/]+)/' => 'FundInitiativeCloseController', + ), + ); + } + + protected function getCustomCapabilities() { + return array( + FundDefaultViewCapability::CAPABILITY => array( + 'caption' => pht('Default view policy for newly created initiatives.'), + ), + FundCreateInitiativesCapability::CAPABILITY => array( + 'default' => PhabricatorPolicies::POLICY_ADMIN, + ), + ); + } + +} diff --git a/src/applications/fund/capability/FundCreateInitiativesCapability.php b/src/applications/fund/capability/FundCreateInitiativesCapability.php new file mode 100644 index 0000000000..d9371aceab --- /dev/null +++ b/src/applications/fund/capability/FundCreateInitiativesCapability.php @@ -0,0 +1,16 @@ +id = $data['id']; + } + + public function processRequest() { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + $initiative = id(new FundInitiativeQuery()) + ->setViewer($viewer) + ->withIDs(array($this->id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$initiative) { + return new Aphront404Response(); + } + + $initiative_uri = '/'.$initiative->getMonogram(); + + $is_close = !$initiative->isClosed(); + + if ($request->isFormPost()) { + $type_status = FundInitiativeTransaction::TYPE_STATUS; + + if ($is_close) { + $new_status = FundInitiative::STATUS_CLOSED; + } else { + $new_status = FundInitiative::STATUS_OPEN; + } + + $xaction = id(new FundInitiativeTransaction()) + ->setTransactionType($type_status) + ->setNewValue($new_status); + + $editor = id(new FundInitiativeEditor()) + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnMissingFields(true); + + $editor->applyTransactions($initiative, array($xaction)); + + return id(new AphrontRedirectResponse())->setURI($initiative_uri); + } + + if ($is_close) { + $title = pht('Close Initiative?'); + $body = pht('Really close this initiative?'); + $button_text = pht('Close Initiative'); + } else { + $title = pht('Reopen Initiative?'); + $body = pht('Really reopen this initiative?'); + $button_text = pht('Reopen Initiative'); + } + + return $this->newDialog() + ->setTitle($title) + ->appendParagraph($body) + ->addCancelButton($initiative_uri) + ->addSubmitButton($button_text); + } + +} diff --git a/src/applications/fund/controller/FundInitiativeEditController.php b/src/applications/fund/controller/FundInitiativeEditController.php new file mode 100644 index 0000000000..292f21b1a1 --- /dev/null +++ b/src/applications/fund/controller/FundInitiativeEditController.php @@ -0,0 +1,191 @@ +id = idx($data, 'id'); + } + + public function processRequest() { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + if ($this->id) { + $initiative = id(new FundInitiativeQuery()) + ->setViewer($viewer) + ->withIDs(array($this->id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$initiative) { + return new Aphront404Response(); + } + $is_new = false; + } else { + $initiative = FundInitiative::initializeNewInitiative($viewer); + $is_new = true; + } + + if ($is_new) { + $title = pht('Create Initiative'); + $button_text = pht('Create Initiative'); + $cancel_uri = $this->getApplicationURI(); + } else { + $title = pht( + 'Edit %s %s', + $initiative->getMonogram(), + $initiative->getName()); + $button_text = pht('Save Changes'); + $cancel_uri = '/'.$initiative->getMonogram(); + } + + $e_name = true; + $v_name = $initiative->getName(); + + $v_desc = $initiative->getDescription(); + + if ($is_new) { + $v_projects = array(); + } else { + $v_projects = PhabricatorEdgeQuery::loadDestinationPHIDs( + $initiative->getPHID(), + PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); + $v_projects = array_reverse($v_projects); + } + + $validation_exception = null; + if ($request->isFormPost()) { + $v_name = $request->getStr('name'); + $v_desc = $request->getStr('description'); + $v_view = $request->getStr('viewPolicy'); + $v_edit = $request->getStr('editPolicy'); + $v_projects = $request->getArr('projects'); + + $type_name = FundInitiativeTransaction::TYPE_NAME; + $type_desc = FundInitiativeTransaction::TYPE_DESCRIPTION; + $type_view = PhabricatorTransactions::TYPE_VIEW_POLICY; + $type_edit = PhabricatorTransactions::TYPE_EDIT_POLICY; + + $xactions = array(); + + $xactions[] = id(new FundInitiativeTransaction()) + ->setTransactionType($type_name) + ->setNewValue($v_name); + + $xactions[] = id(new FundInitiativeTransaction()) + ->setTransactionType($type_desc) + ->setNewValue($v_desc); + + $xactions[] = id(new FundInitiativeTransaction()) + ->setTransactionType($type_view) + ->setNewValue($v_view); + + $xactions[] = id(new FundInitiativeTransaction()) + ->setTransactionType($type_edit) + ->setNewValue($v_edit); + + $proj_edge_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; + $xactions[] = id(new FundInitiativeTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) + ->setMetadataValue('edge:type', $proj_edge_type) + ->setNewValue(array('=' => array_fuse($v_projects))); + + $editor = id(new FundInitiativeEditor()) + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true); + + try { + $editor->applyTransactions($initiative, $xactions); + + return id(new AphrontRedirectResponse()) + ->setURI('/'.$initiative->getMonogram()); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + $validation_exception = $ex; + + $e_name = $ex->getShortMessage($type_name); + + $initiative->setViewPolicy($v_view); + $initiative->setEditPolicy($v_edit); + } + } + + $policies = id(new PhabricatorPolicyQuery()) + ->setViewer($viewer) + ->setObject($initiative) + ->execute(); + + if ($v_projects) { + $project_handles = $this->loadViewerHandles($v_projects); + } else { + $project_handles = array(); + } + + $form = id(new AphrontFormView()) + ->setUser($viewer) + ->appendChild( + id(new AphrontFormTextControl()) + ->setName('name') + ->setLabel(pht('Name')) + ->setValue($v_name) + ->setError($e_name)) + ->appendChild( + id(new PhabricatorRemarkupControl()) + ->setName('description') + ->setLabel(pht('Description')) + ->setValue($v_desc)) + ->appendChild( + id(new AphrontFormTokenizerControl()) + ->setLabel(pht('Projects')) + ->setName('projects') + ->setValue($project_handles) + ->setDatasource(new PhabricatorProjectDatasource())) + ->appendChild( + id(new AphrontFormPolicyControl()) + ->setName('viewPolicy') + ->setPolicyObject($initiative) + ->setCapability(PhabricatorPolicyCapability::CAN_VIEW) + ->setPolicies($policies)) + ->appendChild( + id(new AphrontFormPolicyControl()) + ->setName('editPolicy') + ->setPolicyObject($initiative) + ->setCapability(PhabricatorPolicyCapability::CAN_EDIT) + ->setPolicies($policies)) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue($button_text) + ->addCancelButton($cancel_uri)); + + $crumbs = $this->buildApplicationCrumbs(); + if ($is_new) { + $crumbs->addTextCrumb(pht('Create Initiative')); + } else { + $crumbs->addTextCrumb( + $initiative->getMonogram(), + '/'.$initiative->getMonogram()); + $crumbs->addTextCrumb(pht('Edit')); + } + + $box = id(new PHUIObjectBoxView()) + ->setValidationException($validation_exception) + ->setHeaderText($title) + ->appendChild($form); + + return $this->buildApplicationPage( + array( + $crumbs, + $box, + ), + array( + 'title' => $title, + )); + } + +} diff --git a/src/applications/fund/controller/FundInitiativeListController.php b/src/applications/fund/controller/FundInitiativeListController.php new file mode 100644 index 0000000000..c14bf986cc --- /dev/null +++ b/src/applications/fund/controller/FundInitiativeListController.php @@ -0,0 +1,53 @@ +queryKey = idx($data, 'queryKey'); + } + + public function processRequest() { + $request = $this->getRequest(); + $controller = id(new PhabricatorApplicationSearchController($request)) + ->setQueryKey($this->queryKey) + ->setSearchEngine(new FundInitiativeSearchEngine()) + ->setNavigation($this->buildSideNavView()); + + return $this->delegateToController($controller); + } + + public function buildSideNavView() { + $user = $this->getRequest()->getUser(); + + $nav = new AphrontSideNavFilterView(); + $nav->setBaseURI(new PhutilURI($this->getApplicationURI())); + + id(new FundInitiativeSearchEngine()) + ->setViewer($user) + ->addNavigationItems($nav->getMenu()); + $nav->selectFilter(null); + + return $nav; + } + + public function buildApplicationCrumbs() { + $crumbs = parent::buildApplicationCrumbs(); + + $can_create = $this->hasApplicationCapability( + FundCreateInitiativesCapability::CAPABILITY); + + $crumbs->addAction( + id(new PHUIListItemView()) + ->setName(pht('Create Initiative')) + ->setHref($this->getApplicationURI('create/')) + ->setIcon('fa-plus-square') + ->setDisabled(!$can_create) + ->setWorkflow(!$can_create)); + + return $crumbs; + } + +} diff --git a/src/applications/fund/controller/FundInitiativeViewController.php b/src/applications/fund/controller/FundInitiativeViewController.php new file mode 100644 index 0000000000..794380e409 --- /dev/null +++ b/src/applications/fund/controller/FundInitiativeViewController.php @@ -0,0 +1,149 @@ +id = $data['id']; + } + + public function processRequest() { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + $initiative = id(new FundInitiativeQuery()) + ->setViewer($viewer) + ->withIDs(array($this->id)) + ->executeOne(); + if (!$initiative) { + return new Aphront404Response(); + } + + $crumbs = $this->buildApplicationCrumbs(); + $crumbs->addTextCrumb($initiative->getMonogram()); + + $title = pht( + '%s %s', + $initiative->getMonogram(), + $initiative->getName()); + + if ($initiative->isClosed()) { + $status_icon = 'fa-times'; + $status_color = 'bluegrey'; + } else { + $status_icon = 'fa-check'; + $status_color = 'bluegrey'; + } + $status_name = idx( + FundInitiative::getStatusNameMap(), + $initiative->getStatus()); + + $header = id(new PHUIHeaderView()) + ->setObjectName($initiative->getMonogram()) + ->setHeader($initiative->getName()) + ->setUser($viewer) + ->setPolicyObject($initiative) + ->setStatus($status_icon, $status_color, $status_name); + + $properties = $this->buildPropertyListView($initiative); + $actions = $this->buildActionListView($initiative); + $properties->setActionList($actions); + + $box = id(new PHUIObjectBoxView()) + ->setHeader($header) + ->appendChild($properties); + + $xactions = id(new FundInitiativeTransactionQuery()) + ->setViewer($viewer) + ->withObjectPHIDs(array($initiative->getPHID())) + ->execute(); + + $timeline = id(new PhabricatorApplicationTransactionView()) + ->setUser($viewer) + ->setObjectPHID($initiative->getPHID()) + ->setTransactions($xactions); + + return $this->buildApplicationPage( + array( + $crumbs, + $box, + $timeline, + ), + array( + 'title' => $title, + )); + } + + private function buildPropertyListView(FundInitiative $initiative) { + $viewer = $this->getRequest()->getUser(); + + $view = id(new PHUIPropertyListView()) + ->setUser($viewer) + ->setObject($initiative); + + $owner_phid = $initiative->getOwnerPHID(); + $this->loadHandles(array($owner_phid)); + + $view->addProperty( + pht('Owner'), + $this->getHandle($owner_phid)->renderLink()); + + $view->invokeWillRenderEvent(); + + $description = $initiative->getDescription(); + if (strlen($description)) { + $description = PhabricatorMarkupEngine::renderOneObject( + id(new PhabricatorMarkupOneOff())->setContent($description), + 'default', + $viewer); + + $view->addSectionHeader(pht('Description')); + $view->addTextContent($description); + } + + return $view; + } + + private function buildActionListView(FundInitiative $initiative) { + $viewer = $this->getRequest()->getUser(); + $id = $initiative->getID(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $initiative, + PhabricatorPolicyCapability::CAN_EDIT); + + $view = id(new PhabricatorActionListView()) + ->setUser($viewer) + ->setObject($initiative); + + $view->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Edit Initiative')) + ->setIcon('fa-pencil') + ->setDisabled(!$can_edit) + ->setWorkflow(!$can_edit) + ->setHref($this->getApplicationURI("/edit/{$id}/"))); + + if ($initiative->isClosed()) { + $close_name = pht('Reopen Initiative'); + $close_icon = 'fa-check'; + } else { + $close_name = pht('Close Initiative'); + $close_icon = 'fa-times'; + } + + $view->addAction( + id(new PhabricatorActionView()) + ->setName($close_name) + ->setIcon($close_icon) + ->setDisabled(!$can_edit) + ->setWorkflow(true) + ->setHref($this->getApplicationURI("/close/{$id}/"))); + + return $view; + } + +} diff --git a/src/applications/fund/editor/FundBackingEditor.php b/src/applications/fund/editor/FundBackingEditor.php new file mode 100644 index 0000000000..bd34e0a8d5 --- /dev/null +++ b/src/applications/fund/editor/FundBackingEditor.php @@ -0,0 +1,13 @@ +getTransactionType()) { + case FundInitiativeTransaction::TYPE_NAME: + return $object->getName(); + case FundInitiativeTransaction::TYPE_DESCRIPTION: + return $object->getDescription(); + case FundInitiativeTransaction::TYPE_STATUS: + return $object->getStatus(); + } + + return parent::getCustomTransactionOldValue($object, $xaction); + } + + protected function getCustomTransactionNewValue( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + + switch ($xaction->getTransactionType()) { + case FundInitiativeTransaction::TYPE_NAME: + case FundInitiativeTransaction::TYPE_DESCRIPTION: + case FundInitiativeTransaction::TYPE_STATUS: + return $xaction->getNewValue(); + } + + return parent::getCustomTransactionNewValue($object, $xaction); + } + + protected function applyCustomInternalTransaction( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + + switch ($xaction->getTransactionType()) { + case FundInitiativeTransaction::TYPE_NAME: + $object->setName($xaction->getNewValue()); + return; + case FundInitiativeTransaction::TYPE_DESCRIPTION: + $object->setDescription($xaction->getNewValue()); + return; + case FundInitiativeTransaction::TYPE_STATUS: + $object->setStatus($xaction->getNewValue()); + return; + case PhabricatorTransactions::TYPE_SUBSCRIBERS: + case PhabricatorTransactions::TYPE_EDGE: + return; + } + + return parent::applyCustomInternalTransaction($object, $xaction); + } + + protected function applyCustomExternalTransaction( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + + switch ($xaction->getTransactionType()) { + case FundInitiativeTransaction::TYPE_NAME: + case FundInitiativeTransaction::TYPE_DESCRIPTION: + case FundInitiativeTransaction::TYPE_STATUS: + case PhabricatorTransactions::TYPE_SUBSCRIBERS: + case PhabricatorTransactions::TYPE_EDGE: + return; + } + + return parent::applyCustomExternalTransaction($object, $xaction); + } + + protected function validateTransaction( + PhabricatorLiskDAO $object, + $type, + array $xactions) { + + $errors = parent::validateTransaction($object, $type, $xactions); + + switch ($type) { + case FundInitiativeTransaction::TYPE_NAME: + $missing = $this->validateIsEmptyTextField( + $object->getName(), + $xactions); + + if ($missing) { + $error = new PhabricatorApplicationTransactionValidationError( + $type, + pht('Required'), + pht('Initiative name is required.'), + nonempty(last($xactions), null)); + + $error->setIsMissingFieldError(true); + $errors[] = $error; + } + break; + } + + return $errors; + } + + +} diff --git a/src/applications/fund/phid/FundBackingPHIDType.php b/src/applications/fund/phid/FundBackingPHIDType.php new file mode 100644 index 0000000000..7bd91fe71a --- /dev/null +++ b/src/applications/fund/phid/FundBackingPHIDType.php @@ -0,0 +1,41 @@ +withPHIDs($phids); + } + + public function loadHandles( + PhabricatorHandleQuery $query, + array $handles, + array $objects) { + + foreach ($handles as $phid => $handle) { + $initiative = $objects[$phid]; + + $id = $initiative->getID(); + $monogram = $initiative->getMonogram(); + $name = $initiative->getName(); + + $handle->setName($name); + $handle->setFullName("{$monogram} {$name}"); + $handle->setURI("/fund/view/{$id}/"); + } + } + +} diff --git a/src/applications/fund/phid/FundInitiativePHIDType.php b/src/applications/fund/phid/FundInitiativePHIDType.php new file mode 100644 index 0000000000..b0e384c063 --- /dev/null +++ b/src/applications/fund/phid/FundInitiativePHIDType.php @@ -0,0 +1,74 @@ +withPHIDs($phids); + } + + public function loadHandles( + PhabricatorHandleQuery $query, + array $handles, + array $objects) { + + foreach ($handles as $phid => $handle) { + $initiative = $objects[$phid]; + + $id = $initiative->getID(); + $monogram = $initiative->getMonogram(); + $name = $initiative->getName(); + + if ($initiative->isClosed()) { + $handle->setStatus(PhabricatorObjectHandleStatus::STATUS_CLOSED); + } + + $handle->setName($name); + $handle->setFullName("{$monogram} {$name}"); + $handle->setURI("/I{$id}"); + } + } + + public function canLoadNamedObject($name) { + return preg_match('/^I\d*[1-9]\d*$/i', $name); + } + + public function loadNamedObjects( + PhabricatorObjectQuery $query, + array $names) { + + $id_map = array(); + foreach ($names as $name) { + $id = (int)substr($name, 1); + $id_map[$id][] = $name; + } + + $objects = id(new FundInitiativeQuery()) + ->setViewer($query->getViewer()) + ->withIDs(array_keys($id_map)) + ->execute(); + + $results = array(); + foreach ($objects as $id => $object) { + foreach (idx($id_map, $id, array()) as $name) { + $results[$name] = $object; + } + } + + return $results; + } + +} diff --git a/src/applications/fund/query/FundBackingQuery.php b/src/applications/fund/query/FundBackingQuery.php new file mode 100644 index 0000000000..68aa999e24 --- /dev/null +++ b/src/applications/fund/query/FundBackingQuery.php @@ -0,0 +1,60 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + protected function loadPage() { + $table = new FundBacking(); + $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); + } + + private function buildWhereClause(AphrontDatabaseConnection $conn_r) { + $where = array(); + + $where[] = $this->buildPagingClause($conn_r); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn_r, + 'id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn_r, + 'phid IN (%Ls)', + $this->phids); + } + + return $this->formatWhereClause($where); + } + + public function getQueryApplicationClass() { + return 'PhabricatorFundApplication'; + } + +} diff --git a/src/applications/fund/query/FundBackingTransactionQuery.php b/src/applications/fund/query/FundBackingTransactionQuery.php new file mode 100644 index 0000000000..34ad3ea647 --- /dev/null +++ b/src/applications/fund/query/FundBackingTransactionQuery.php @@ -0,0 +1,10 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + public function withOwnerPHIDs(array $phids) { + $this->ownerPHIDs = $phids; + return $this; + } + + public function withStatuses(array $statuses) { + $this->statuses = $statuses; + return $this; + } + + public function needProjectPHIDs($need) { + $this->needProjectPHIDs = $need; + return $this; + } + + protected function loadPage() { + $table = new FundInitiative(); + $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); + } + + protected function didFilterPage(array $initiatives) { + + if ($this->needProjectPHIDs) { + $edge_query = id(new PhabricatorEdgeQuery()) + ->withSourcePHIDs(mpull($initiatives, 'getPHID')) + ->withEdgeTypes( + array( + PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, + )); + $edge_query->execute(); + + foreach ($initiatives as $initiative) { + $phids = $edge_query->getDestinationPHIDs( + array( + $initiative->getPHID(), + )); + $initiative->attachProjectPHIDs($phids); + } + } + + return $initiatives; + } + + private function buildWhereClause(AphrontDatabaseConnection $conn_r) { + $where = array(); + + $where[] = $this->buildPagingClause($conn_r); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn_r, + 'id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn_r, + 'phid IN (%Ls)', + $this->phids); + } + + if ($this->ownerPHIDs !== null) { + $where[] = qsprintf( + $conn_r, + 'ownerPHID IN (%Ls)', + $this->ownerPHIDs); + } + + if ($this->statuses !== null) { + $where[] = qsprintf( + $conn_r, + 'status IN (%Ls)', + $this->statuses); + } + + return $this->formatWhereClause($where); + } + + public function getQueryApplicationClass() { + return 'PhabricatorFundApplication'; + } + +} diff --git a/src/applications/fund/query/FundInitiativeSearchEngine.php b/src/applications/fund/query/FundInitiativeSearchEngine.php new file mode 100644 index 0000000000..17e5f585f8 --- /dev/null +++ b/src/applications/fund/query/FundInitiativeSearchEngine.php @@ -0,0 +1,180 @@ +setParameter( + 'ownerPHIDs', + $this->readUsersFromRequest($request, 'owners')); + + $saved->setParameter( + 'statuses', + $this->readListFromRequest($request, 'statuses')); + + return $saved; + } + + public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { + $query = id(new FundInitiativeQuery()) + ->needProjectPHIDs(true); + + $owner_phids = $saved->getParameter('ownerPHIDs'); + if ($owner_phids) { + $query->withOwnerPHIDs($owner_phids); + } + + $statuses = $saved->getParameter('statuses'); + if ($statuses) { + $query->withStatuses($statuses); + } + + return $query; + } + + public function buildSearchForm( + AphrontFormView $form, + PhabricatorSavedQuery $saved) { + + $statuses = $saved->getParameter('statuses', array()); + $statuses = array_fuse($statuses); + + $owner_phids = $saved->getParameter('ownerPHIDs', array()); + + $all_phids = array_mergev( + array( + $owner_phids, + )); + + $handles = id(new PhabricatorHandleQuery()) + ->setViewer($this->requireViewer()) + ->withPHIDs($all_phids) + ->execute(); + + $status_map = FundInitiative::getStatusNameMap(); + $status_control = id(new AphrontFormCheckboxControl()) + ->setLabel(pht('Statuses')); + foreach ($status_map as $status => $name) { + $status_control->addCheckbox( + 'statuses[]', + $status, + $name, + isset($statuses[$status])); + } + + $form + ->appendChild( + id(new AphrontFormTokenizerControl()) + ->setLabel(pht('Owners')) + ->setName('owners') + ->setDatasource(new PhabricatorPeopleDatasource()) + ->setValue(array_select_keys($handles, $owner_phids))) + ->appendChild($status_control); + } + + protected function getURI($path) { + return '/fund/'.$path; + } + + public function getBuiltinQueryNames() { + $names = array(); + + $names['open'] = pht('Open Initiatives'); + if ($this->requireViewer()->isLoggedIn()) { + $names['owned'] = pht('Owned Initiatives'); + } + $names['all'] = pht('All Initiatives'); + + return $names; + } + + public function buildSavedQueryFromBuiltin($query_key) { + $query = $this->newSavedQuery(); + $query->setQueryKey($query_key); + + switch ($query_key) { + case 'all': + return $query; + case 'owned': + return $query->setParameter( + 'ownerPHIDs', + array( + $this->requireViewer()->getPHID(), + )); + case 'open': + return $query->setParameter( + 'statuses', + array( + FundInitiative::STATUS_OPEN, + )); + } + + return parent::buildSavedQueryFromBuiltin($query_key); + } + + protected function getRequiredHandlePHIDsForResultList( + array $initiatives, + PhabricatorSavedQuery $query) { + + $phids = array(); + foreach ($initiatives as $initiative) { + $phids[] = $initiative->getOwnerPHID(); + foreach ($initiative->getProjectPHIDs() as $project_phid) { + $phids[] = $project_phid; + } + } + + return $phids; + } + + protected function renderResultList( + array $initiatives, + PhabricatorSavedQuery $query, + array $handles) { + assert_instances_of($initiatives, 'FundInitiative'); + + $viewer = $this->requireViewer(); + + $list = id(new PHUIObjectItemListView()); + foreach ($initiatives as $initiative) { + $owner_handle = $handles[$initiative->getOwnerPHID()]; + + $item = id(new PHUIObjectItemView()) + ->setObjectName($initiative->getMonogram()) + ->setHeader($initiative->getName()) + ->setHref('/'.$initiative->getMonogram()) + ->addByline(pht('Owner: %s', $owner_handle->renderLink())); + + if ($initiative->isClosed()) { + $item->setDisabled(true); + } + + $project_handles = array_select_keys( + $handles, + $initiative->getProjectPHIDs()); + if ($project_handles) { + $item->addAttribute( + id(new PHUIHandleTagListView()) + ->setLimit(4) + ->setSlim(true) + ->setHandles($project_handles)); + } + + $list->addItem($item); + } + + + return $list; + } + +} diff --git a/src/applications/fund/query/FundInitiativeTransactionQuery.php b/src/applications/fund/query/FundInitiativeTransactionQuery.php new file mode 100644 index 0000000000..3c178ec645 --- /dev/null +++ b/src/applications/fund/query/FundInitiativeTransactionQuery.php @@ -0,0 +1,10 @@ +getEngine()->getConfig('viewer'); + + return id(new FundInitiativeQuery()) + ->setViewer($viewer) + ->withIDs($ids) + ->execute(); + } + +} diff --git a/src/applications/fund/storage/FundBacking.php b/src/applications/fund/storage/FundBacking.php new file mode 100644 index 0000000000..31104bf2ed --- /dev/null +++ b/src/applications/fund/storage/FundBacking.php @@ -0,0 +1,85 @@ + true, + ) + parent::getConfiguration(); + } + + public function generatePHID() { + return PhabricatorPHID::generateNewPHID(FundBackingPHIDType::TYPECONST); + } + + public function getProperty($key, $default = null) { + return idx($this->properties, $key, $default); + } + + public function setProperty($key, $value) { + $this->properties[$key] = $value; + return $this; + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + ); + } + + public function getPolicy($capability) { + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + // If we have the initiative, use the initiative's policy. + // Otherwise, return NOONE. This allows the backer to continue seeing + // a backing even if they're no longer allowed to see the initiative. + + $initiative = $this->getInitiative(); + if ($initiative) { + return $initiative->getPolicy($capability); + } + return PhabricatorPolicies::POLICY_NOONE; + } + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return ($viewer->getPHID() == $this->getBackerPHID()); + } + + public function describeAutomaticCapability($capability) { + return pht('A backer can always see what they have backed.'); + } + + +/* -( PhabricatorApplicationTransactionInterface )------------------------- */ + + + public function getApplicationTransactionEditor() { + return new FundBackingEditor(); + } + + public function getApplicationTransactionObject() { + return $this; + } + + public function getApplicationTransactionTemplate() { + return new FundBackingTransaction(); + } + +} diff --git a/src/applications/fund/storage/FundBackingTransaction.php b/src/applications/fund/storage/FundBackingTransaction.php new file mode 100644 index 0000000000..79fd55f0bd --- /dev/null +++ b/src/applications/fund/storage/FundBackingTransaction.php @@ -0,0 +1,18 @@ + pht('Open'), + self::STATUS_CLOSED => pht('Closed'), + ); + } + + public static function initializeNewInitiative(PhabricatorUser $actor) { + $app = id(new PhabricatorApplicationQuery()) + ->setViewer($actor) + ->withClasses(array('PhabricatorFundApplication')) + ->executeOne(); + + $view_policy = $app->getPolicy(FundDefaultViewCapability::CAPABILITY); + + return id(new FundInitiative()) + ->setOwnerPHID($actor->getPHID()) + ->setViewPolicy($view_policy) + ->setEditPolicy($actor->getPHID()) + ->setStatus(self::STATUS_OPEN); + } + + public function getConfiguration() { + return array( + self::CONFIG_AUX_PHID => true, + ) + parent::getConfiguration(); + } + + public function generatePHID() { + return PhabricatorPHID::generateNewPHID(FundInitiativePHIDType::TYPECONST); + } + + public function getMonogram() { + return 'I'.$this->getID(); + } + + public function getProjectPHIDs() { + return $this->assertAttached($this->projectPHIDs); + } + + public function attachProjectPHIDs(array $phids) { + $this->projectPHIDs = $phids; + return $this; + } + + public function isClosed() { + return ($this->getStatus() == self::STATUS_CLOSED); + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + public function getPolicy($capability) { + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + return $this->getViewPolicy(); + case PhabricatorPolicyCapability::CAN_EDIT: + return $this->getEditPolicy(); + } + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return false; + } + + public function describeAutomaticCapability($capability) { + return null; + } + + +/* -( PhabricatorApplicationTransactionInterface )------------------------- */ + + + public function getApplicationTransactionEditor() { + return new FundInitiativeEditor(); + } + + public function getApplicationTransactionObject() { + return $this; + } + + public function getApplicationTransactionTemplate() { + return new FundInitiativeTransaction(); + } + + +/* -( PhabricatorSubscribableInterface )----------------------------------- */ + + + public function isAutomaticallySubscribed($phid) { + return ($phid == $this->getOwnerPHID()); + } + + public function shouldShowSubscribersProperty() { + return true; + } + + public function shouldAllowSubscription($phid) { + return true; + } + + +/* -( PhabricatorTokenRecevierInterface )---------------------------------- */ + + + public function getUsersToNotifyOfTokenGiven() { + return array( + $this->getOwnerPHID(), + ); + } + + +/* -( PhabricatorDestructibleInterface )----------------------------------- */ + + + public function destroyObjectPermanently( + PhabricatorDestructionEngine $engine) { + + $this->openTransaction(); + $this->delete(); + $this->saveTransaction(); + } + +} diff --git a/src/applications/fund/storage/FundInitiativeTransaction.php b/src/applications/fund/storage/FundInitiativeTransaction.php new file mode 100644 index 0000000000..fcb75c1fab --- /dev/null +++ b/src/applications/fund/storage/FundInitiativeTransaction.php @@ -0,0 +1,136 @@ +getAuthorPHID(); + $object_phid = $this->getObjectPHID(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $type = $this->getTransactionType(); + switch ($type) { + case FundInitiativeTransaction::TYPE_NAME: + if ($old === null) { + return pht( + '%s created this initiative.', + $this->renderHandleLink($author_phid)); + } else { + return pht( + '%s renamed this initiative from "%s" to "%s".', + $this->renderHandleLink($author_phid), + $old, + $new); + } + break; + case FundInitiativeTransaction::TYPE_DESCRIPTION: + return pht( + '%s edited the description of this initiative.', + $this->renderHandleLink($author_phid)); + case FundInitiativeTransaction::TYPE_STATUS: + switch ($new) { + case FundInitiative::STATUS_OPEN: + return pht( + '%s reopened this initiative.', + $this->renderHandleLink($author_phid)); + case FundInitiative::STATUS_CLOSED: + return pht( + '%s closed this initiative.', + $this->renderHandleLink($author_phid)); + } + break; + } + + return parent::getTitle(); + } + + public function getTitleForFeed(PhabricatorFeedStory $story) { + $author_phid = $this->getAuthorPHID(); + $object_phid = $this->getObjectPHID(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $type = $this->getTransactionType(); + switch ($type) { + case FundInitiativeTransaction::TYPE_NAME: + if ($old === null) { + return pht( + '%s created %s.', + $this->renderHandleLink($author_phid), + $this->renderHandleLink($object_phid)); + + } else { + return pht( + '%s renamed %s.', + $this->renderHandleLink($author_phid), + $this->renderHandleLink($object_phid)); + } + break; + case FundInitiativeTransaction::TYPE_DESCRIPTION: + return pht( + '%s updated the description for %s.', + $this->renderHandleLink($author_phid), + $this->renderHandleLink($object_phid)); + case FundInitiativeTransaction::TYPE_STATUS: + switch ($new) { + case FundInitiative::STATUS_OPEN: + return pht( + '%s reopened %s.', + $this->renderHandleLink($author_phid), + $this->renderHandleLink($object_phid)); + case FundInitiative::STATUS_CLOSED: + return pht( + '%s closed %s.', + $this->renderHandleLink($author_phid), + $this->renderHandleLink($object_phid)); + } + break; + } + + return parent::getTitleForFeed($story); + } + + public function shouldHide() { + $old = $this->getOldValue(); + switch ($this->getTransactionType()) { + case FundInitiativeTransaction::TYPE_DESCRIPTION: + return ($old === null); + } + return parent::shouldHide(); + } + + public function hasChangeDetails() { + switch ($this->getTransactionType()) { + case FundInitiativeTransaction::TYPE_DESCRIPTION: + return ($this->getOldValue() !== null); + } + + return parent::hasChangeDetails(); + } + + public function renderChangeDetails(PhabricatorUser $viewer) { + return $this->renderTextCorpusChangeDetails( + $viewer, + $this->getOldValue(), + $this->getNewValue()); + } +} diff --git a/src/infrastructure/storage/patch/PhabricatorBuiltinPatchList.php b/src/infrastructure/storage/patch/PhabricatorBuiltinPatchList.php index e05472721d..b36c2d157b 100644 --- a/src/infrastructure/storage/patch/PhabricatorBuiltinPatchList.php +++ b/src/infrastructure/storage/patch/PhabricatorBuiltinPatchList.php @@ -119,6 +119,7 @@ final class PhabricatorBuiltinPatchList extends PhabricatorSQLPatchList { 'db.phragment' => array(), 'db.dashboard' => array(), 'db.system' => array(), + 'db.fund' => array(), '0000.legacy.sql' => array( 'legacy' => 0, ),