diff --git a/src/__celerity_resource_map__.php b/src/__celerity_resource_map__.php
index 688f894910..2ef3a110c1 100644
--- a/src/__celerity_resource_map__.php
+++ b/src/__celerity_resource_map__.php
@@ -258,7 +258,7 @@ celerity_register_resource_map(array(
),
'herald-rule-editor' =>
array(
- 'uri' => '/res/ba957508/rsrc/js/application/herald/HeraldRuleEditor.js',
+ 'uri' => '/res/4d6dff2b/rsrc/js/application/herald/HeraldRuleEditor.js',
'type' => 'js',
'requires' =>
array(
@@ -271,6 +271,7 @@ celerity_register_resource_map(array(
6 => 'javelin-typeahead-preloaded-source',
7 => 'javelin-stratcom',
8 => 'javelin-json',
+ 9 => 'phabricator-prefab',
),
'disk' => '/rsrc/js/application/herald/HeraldRuleEditor.js',
),
@@ -665,6 +666,24 @@ celerity_register_resource_map(array(
),
'disk' => '/rsrc/js/application/phriction/phriction-document-preview.js',
),
+ 'javelin-behavior-projects-resource-editor' =>
+ array(
+ 'uri' => '/res/a54d5616/rsrc/js/application/projects/projects-resource-editor.js',
+ 'type' => 'js',
+ 'requires' =>
+ array(
+ 0 => 'javelin-behavior',
+ 1 => 'phabricator-prefab',
+ 2 => 'multirow-row-manager',
+ 3 => 'javelin-tokenizer',
+ 4 => 'javelin-typeahead-preloaded-source',
+ 5 => 'javelin-typeahead',
+ 6 => 'javelin-dom',
+ 7 => 'javelin-json',
+ 8 => 'javelin-util',
+ ),
+ 'disk' => '/rsrc/js/application/projects/projects-resource-editor.js',
+ ),
'javelin-behavior-refresh-csrf' =>
array(
'uri' => '/res/39aa51f7/rsrc/js/application/core/behavior-refresh-csrf.js',
@@ -1094,6 +1113,18 @@ celerity_register_resource_map(array(
),
'disk' => '/rsrc/css/application/objectselector/object-selector.css',
),
+ 'phabricator-prefab' =>
+ array(
+ 'uri' => '/res/5784a112/rsrc/js/application/core/Prefab.js',
+ 'type' => 'js',
+ 'requires' =>
+ array(
+ 0 => 'javelin-install',
+ 1 => 'javelin-util',
+ 2 => 'javelin-dom',
+ ),
+ 'disk' => '/rsrc/js/application/core/Prefab.js',
+ ),
'phabricator-profile-css' =>
array(
'uri' => '/res/ebe1ac2f/rsrc/css/application/profile/profile-view.css',
@@ -1169,6 +1200,15 @@ celerity_register_resource_map(array(
),
'disk' => '/rsrc/css/application/phriction/phriction-document-css.css',
),
+ 'project-edit-css' =>
+ array(
+ 'uri' => '/res/c192b5f9/rsrc/css/application/projects/project-edit.css',
+ 'type' => 'css',
+ 'requires' =>
+ array(
+ ),
+ 'disk' => '/rsrc/css/application/projects/project-edit.css',
+ ),
'syntax-highlighting-css' =>
array(
'uri' => '/res/e5cc3d88/rsrc/css/core/syntax.css',
diff --git a/src/applications/project/controller/profileedit/PhabricatorProjectProfileEditController.php b/src/applications/project/controller/profileedit/PhabricatorProjectProfileEditController.php
index ae9710eca2..db0514a5fb 100644
--- a/src/applications/project/controller/profileedit/PhabricatorProjectProfileEditController.php
+++ b/src/applications/project/controller/profileedit/PhabricatorProjectProfileEditController.php
@@ -49,8 +49,12 @@ class PhabricatorProjectProfileEditController
$options = PhabricatorProjectStatus::getStatusMap();
+ $affiliations = $project->loadAffiliations();
+ $affiliations = mpull($affiliations, null, 'getUserPHID');
+
$e_name = true;
$errors = array();
+ $state = null;
if ($request->isFormPost()) {
$project->setName($request->getStr('name'));
$project->setStatus($request->getStr('status'));
@@ -89,12 +93,97 @@ class PhabricatorProjectProfileEditController
}
}
+ $resources = $request->getStr('resources');
+ $resources = json_decode($resources, true);
+ if (!is_array($resources)) {
+ throw new Exception(
+ "Project resource information was not correctly encoded in the ".
+ "request.");
+ }
+
+ $state = array();
+ foreach ($resources as $resource) {
+ $user_phid = $resource['phid'];
+ if (!$user_phid) {
+ continue;
+ }
+ if (isset($state[$user_phid])) {
+ // TODO: We should deal with this better -- the user has entered
+ // the same resource more than once.
+ }
+ $state[$user_phid] = array(
+ 'phid' => $user_phid,
+ 'status' => $resource['status'],
+ 'role' => $resource['role'],
+ 'owner' => $resource['owner'],
+ );
+ }
+
+ $all_phids = array_merge(array_keys($state), array_keys($affiliations));
+ $all_phids = array_unique($all_phids);
+
+ $delete_affiliations = array();
+ $save_affiliations = array();
+ foreach ($all_phids as $phid) {
+ $old = idx($affiliations, $phid);
+ $new = idx($state, $phid);
+
+ if ($old && !$new) {
+ $delete_affiliations[] = $affiliations[$phid];
+ continue;
+ }
+
+ if (!$old) {
+ $affil = new PhabricatorProjectAffiliation();
+ $affil->setUserPHID($phid);
+ } else {
+ $affil = $old;
+ }
+
+ $affil->setRole((string)$new['role']);
+ $affil->setStatus((string)$new['status']);
+ $affil->setIsOwner((int)$new['owner']);
+
+ $save_affiliations[] = $affil;
+ }
+
if (!$errors) {
$project->save();
$profile->setProjectPHID($project->getPHID());
$profile->save();
+
+ foreach ($delete_affiliations as $affil) {
+ $affil->delete();
+ }
+
+ foreach ($save_affiliations as $save) {
+ $save->setProjectPHID($project->getPHID());
+ $save->save();
+ }
+
return id(new AphrontRedirectResponse())
->setURI('/project/view/'.$project->getID().'/');
+ } else {
+ $phids = array_keys($state);
+ $handles = id(new PhabricatorObjectHandleData($phids))->loadHandles();
+ foreach ($state as $phid => $info) {
+ $state[$phid]['name'] = $handles[$phid]->getFullName();
+ }
+ }
+ } else {
+ $phids = mpull($affiliations, 'getUserPHID');
+ $handles = id(new PhabricatorObjectHandleData($phids))->loadHandles();
+
+ $state = array();
+ foreach ($affiliations as $affil) {
+ $user_phid = $affil->getUserPHID();
+ $state[] = array(
+ 'phid' => $user_phid,
+ 'name' => $handles[$user_phid]->getFullName(),
+ 'status' => $affil->getStatus(),
+ 'role' => $affil->getRole(),
+ 'owner' => $affil->getIsOwner(),
+ );
}
}
@@ -109,8 +198,11 @@ class PhabricatorProjectProfileEditController
$title = 'Edit Project';
$action = '/project/edit/'.$project->getID().'/';
+ require_celerity_resource('project-edit-css');
+
$form = new AphrontFormView();
$form
+ ->setID('project-edit-form')
->setUser($user)
->setAction($action)
->setEncType('multipart/form-data')
@@ -141,11 +233,49 @@ class PhabricatorProjectProfileEditController
id(new AphrontFormFileControl())
->setLabel('Change Image')
->setName('image'))
+ ->appendChild(
+ '
Resources
'.
+ ''.
+ '')
->appendChild(
id(new AphrontFormSubmitControl())
->addCancelButton('/project/view/'.$project->getID().'/')
->setValue('Save'));
+ $template = new AphrontTokenizerTemplateView();
+ $template = $template->render();
+
+ Javelin::initBehavior(
+ 'projects-resource-editor',
+ array(
+ 'root' => 'project-edit-form',
+ 'tokenizerTemplate' => $template,
+ 'tokenizerSource' => '/typeahead/common/users/',
+ 'input' => 'resources',
+ 'state' => array_values($state),
+ ));
+
$panel = new AphrontPanelView();
$panel->setHeader($header_name);
$panel->setWidth(AphrontPanelView::WIDTH_WIDE);
diff --git a/src/applications/project/controller/profileedit/__init__.php b/src/applications/project/controller/profileedit/__init__.php
index 46a80365db..560d1d293f 100644
--- a/src/applications/project/controller/profileedit/__init__.php
+++ b/src/applications/project/controller/profileedit/__init__.php
@@ -13,8 +13,13 @@ phutil_require_module('phabricator', 'applications/files/transform');
phutil_require_module('phabricator', 'applications/phid/handle/data');
phutil_require_module('phabricator', 'applications/project/constants/status');
phutil_require_module('phabricator', 'applications/project/controller/base');
+phutil_require_module('phabricator', 'applications/project/storage/affiliation');
phutil_require_module('phabricator', 'applications/project/storage/profile');
phutil_require_module('phabricator', 'applications/project/storage/project');
+phutil_require_module('phabricator', 'infrastructure/celerity/api');
+phutil_require_module('phabricator', 'infrastructure/javelin/api');
+phutil_require_module('phabricator', 'infrastructure/javelin/markup');
+phutil_require_module('phabricator', 'view/control/tokenizer');
phutil_require_module('phabricator', 'view/form/base');
phutil_require_module('phabricator', 'view/form/control/file');
phutil_require_module('phabricator', 'view/form/control/select');
diff --git a/webroot/rsrc/css/application/projects/project-edit.css b/webroot/rsrc/css/application/projects/project-edit.css
new file mode 100644
index 0000000000..4847a725d4
--- /dev/null
+++ b/webroot/rsrc/css/application/projects/project-edit.css
@@ -0,0 +1,30 @@
+/**
+ * @provides project-edit-css
+ */
+
+.project-resource-table {
+ width: 100%;
+}
+
+.project-resource-table td {
+ padding: 2px 4px;
+ vertical-align: middle;
+}
+
+.project-resource-table td label {
+ font-weight: bold;
+ color: #666666;
+ text-align: right;
+}
+
+.project-resource-table td.user-tokenizer {
+ width: 35%;
+}
+
+.project-resource-table td.role-label {
+ padding-left: 25px;
+}
+
+.project-resource-table td.role input {
+ width: 300px;
+}
diff --git a/webroot/rsrc/js/application/core/Prefab.js b/webroot/rsrc/js/application/core/Prefab.js
new file mode 100644
index 0000000000..fedbb35843
--- /dev/null
+++ b/webroot/rsrc/js/application/core/Prefab.js
@@ -0,0 +1,28 @@
+/**
+ * @provides phabricator-prefab
+ * @requires javelin-install
+ * javelin-util
+ * javelin-dom
+ * @javelin
+ */
+
+/**
+ * Utilities for client-side rendering (the greatest thing in the world).
+ */
+JX.install('Prefab', {
+
+ statics : {
+ renderSelect : function(map, selected, attrs) {
+ var select = JX.$N('select', attrs || {});
+ for (var k in map) {
+ select.options[select.options.length] = new Option(map[k], k);
+ if (k == selected) {
+ select.value = k;
+ }
+ }
+ select.value = select.value || JX.keys(map)[0];
+ return select;
+ }
+ }
+
+});
diff --git a/webroot/rsrc/js/application/herald/HeraldRuleEditor.js b/webroot/rsrc/js/application/herald/HeraldRuleEditor.js
index c058352310..da1aacd672 100644
--- a/webroot/rsrc/js/application/herald/HeraldRuleEditor.js
+++ b/webroot/rsrc/js/application/herald/HeraldRuleEditor.js
@@ -8,6 +8,7 @@
* javelin-typeahead-preloaded-source
* javelin-stratcom
* javelin-json
+ * phabricator-prefab
* @provides herald-rule-editor
* @javelin
*/
@@ -350,20 +351,11 @@ JX.install('HeraldRuleEditor', {
return [action_cell, target_cell];
},
_renderSelect : function(map, selected, sigil) {
- var select = JX.$N(
- 'select',
- {
- style : {width: '250px', margin: '0 .5em 0 0'},
- sigil : sigil
- });
- for (var k in map) {
- select.options[select.options.length] = new Option(map[k], k);
- if (k == selected) {
- select.value = k;
- }
- }
- select.value = select.value || JX.keys(map)[0];
- return select;
+ var attrs = {
+ style : {width: '250px', margin: '0 .5em 0 0'},
+ sigil : sigil
+ };
+ return JX.Prefab.renderSelect(map, selected, attrs);
}
}
});
diff --git a/webroot/rsrc/js/application/projects/projects-resource-editor.js b/webroot/rsrc/js/application/projects/projects-resource-editor.js
new file mode 100644
index 0000000000..9a7e7d7611
--- /dev/null
+++ b/webroot/rsrc/js/application/projects/projects-resource-editor.js
@@ -0,0 +1,122 @@
+/**
+ * @requires javelin-behavior
+ * phabricator-prefab
+ * multirow-row-manager
+ * javelin-tokenizer
+ * javelin-typeahead-preloaded-source
+ * javelin-typeahead
+ * javelin-dom
+ * javelin-json
+ * javelin-util
+ * @provides javelin-behavior-projects-resource-editor
+ * @javelin
+ */
+
+JX.behavior('projects-resource-editor', function(config) {
+
+ var root = JX.$(config.root);
+ var resources_table = JX.DOM.find(root, 'table', 'resources');
+ var manager = new JX.MultirowRowManager(resources_table);
+ var resource_rows = [];
+
+ for (var ii = 0; ii < config.state.length; ii++) {
+ addRow(config.state[ii]);
+ }
+
+ function renderRow(data) {
+
+ var template = JX.$N('div', JX.$H(config.tokenizerTemplate)).firstChild;
+ template.id = '';
+ var datasource = new JX.TypeaheadPreloadedSource(
+ config.tokenizerSource);
+ var typeahead = new JX.Typeahead(template);
+ typeahead.setDatasource(datasource);
+ var tokenizer = new JX.Tokenizer(template);
+ tokenizer.setTypeahead(typeahead);
+ tokenizer.setLimit(1);
+ tokenizer.start();
+
+ if (data.phid) {
+ tokenizer.addToken(data.phid, data.name);
+ }
+
+ var status = JX.Prefab.renderSelect(
+ {'' : 'Current', 'former' : 'Former'},
+ data.status || '');
+
+ var role = JX.$N('input', {type: 'text', value : data.role || ''});
+
+ var ownership = JX.Prefab.renderSelect(
+ {0 : 'Nonowner', 1 : 'Owner'},
+ data.owner || 0);
+
+ var as_object = function() {
+ var tokens = tokenizer.getTokens();
+ return {
+ phid : JX.keys(tokens)[0] || null,
+ status : status.value,
+ role : role.value,
+ owner : ownership.value
+ };
+ }
+
+ var r = [];
+ r.push([null, JX.$N('label', {}, 'User:')]);
+ r.push(['user-tokenizer', template]);
+ r.push(['role-label', JX.$N('label', {}, 'Role:')]);
+ r.push([null, status]);
+ r.push(['role', role]);
+ r.push([null, ownership]);
+
+ for (var ii = 0; ii < r.length; ii++) {
+ r[ii] = JX.$N('td', {className : r[ii][0]}, r[ii][1]);
+ }
+
+ return {
+ nodes : r,
+ dataCallback : as_object
+ };
+ }
+
+ function onaddresource(e) {
+ e.kill();
+ addRow({});
+ }
+
+ function addRow(info) {
+ var data = renderRow(info);
+ var row = manager.addRow(data.nodes);
+ var id = manager.getRowID(row);
+
+ resource_rows[id] = data.dataCallback;
+ }
+
+ function onsubmit(e) {
+ var result = [];
+ for (var ii = 0; ii < resource_rows.length; ii++) {
+ if (resource_rows[ii]) {
+ var obj = resource_rows[ii]();
+ result.push(obj);
+ }
+ }
+ JX.$(config.input).value = JX.JSON.stringify(result);
+ }
+
+ JX.DOM.listen(
+ root,
+ 'click',
+ 'add-resource',
+ onaddresource);
+
+ JX.DOM.listen(
+ root,
+ 'submit',
+ null,
+ onsubmit);
+
+ manager.listen(
+ 'row-removed',
+ function(row_id) {
+ delete resource_rows[row_id];
+ });
+});