launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #06449
[Merge] lp:~wallyworld/launchpad/sharing-picker-933823 into lp:launchpad
Ian Booth has proposed merging lp:~wallyworld/launchpad/sharing-picker-933823 into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #933823 in Launchpad itself: "Create a two-step picker to select the person and the policies to share"
https://bugs.launchpad.net/launchpad/+bug/933823
For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/sharing-picker-933823/+merge/93920
Add a two step picker to choose a person and access policy type for sharing project artefacts.
== Implementation ==
A bit of infrastructure refactoring was required. The vocabulary_filters() method was moved from the inline picker widget class to a method on the browser.vocabulary module. There were 3 places where this functionality was duplicated as well as being required for this new sharing picker. Now all the callsites use the single refactored method.
A new picker subclass lp.registry.disclosure.DisclosurePicker is provided. This has a two step selection process - select the person and then select the access policy type. A bit of work was required to the base picker code was needed to make the events fire properly.
== Demo and QA ==
See screencast
http://people.canonical.com/~ianb/picker-demo.ogv
Go to the product sharing page
eg http://launchpad.dev/firefox/+sharing
Click the +Share link in the right side portlet.
Use the picker to select a person to share with, then select an access policy type.
If the selection is finalised by clicking the button icon or the Select link, the selected person uri and access policy type is logged to the browser console since there's nothing to connect it to yet.
== Tests ==
New yui tests provided for the DisclosurePicker.
New test provided for the ProductSharingView.
Re-ran test_popup, test_widget_template_properties, test_vocabulary_filters, test_itemswidgets
== Lint ==
Checking for conflicts and issues in changed files.
Linting changed files:
lib/canonical/launchpad/icing/css/colours.css
lib/canonical/launchpad/icing/css/forms.css
lib/lp/app/enums.py
lib/lp/app/browser/lazrjs.py
lib/lp/app/browser/vocabulary.py
lib/lp/app/javascript/picker/picker.js
lib/lp/app/javascript/picker/picker_patcher.js
lib/lp/app/widgets/popup.py
lib/lp/bugs/browser/bugtask.py
lib/lp/registry/browser/product.py
lib/lp/registry/browser/tests/test_product.py
lib/lp/registry/javascript/disclosure/
lib/lp/registry/javascript/disclosure/disclosure_person_picker.js
lib/lp/registry/javascript/disclosure/product_sharing.js
lib/lp/registry/javascript/disclosure/tests/
lib/lp/registry/javascript/disclosure/tests/test_disclosure_person_picker.html
lib/lp/registry/javascript/disclosure/tests/test_disclosure_person_picker.js
lib/lp/registry/templates/product-sharing.pt
Lint clean apart from css noise.
--
https://code.launchpad.net/~wallyworld/launchpad/sharing-picker-933823/+merge/93920
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/sharing-picker-933823 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/icing/css/colours.css'
--- lib/canonical/launchpad/icing/css/colours.css 2011-12-05 00:57:34 +0000
+++ lib/canonical/launchpad/icing/css/colours.css 2012-02-21 10:48:21 +0000
@@ -346,3 +346,23 @@
.red {
color: red;
}
+
+.accessPolicyPUBLIC, .accessPolicyPUBLIC a {
+ color: green;
+ }
+
+.accessPolicyPUBLICSECURITY, .accessPolicyPUBLICSECURITY a {
+ color: #1f7dff;
+ }
+
+.accessPolicyEMBARGOEDSECURITY, .accessPolicyEMBARGOEDSECURITY a {
+ color: #ff4500;
+ }
+
+.accessPolicyUSERDATA, .accessPolicyUSERDATA a {
+ color: #800080;
+ }
+
+.accessPolicyPROPRIETARY, .accessPolicyPROPRIETARY a {
+ color: red;
+ }
=== modified file 'lib/canonical/launchpad/icing/css/forms.css'
--- lib/canonical/launchpad/icing/css/forms.css 2012-01-18 00:15:38 +0000
+++ lib/canonical/launchpad/icing/css/forms.css 2012-02-21 10:48:21 +0000
@@ -224,6 +224,18 @@
font-size: 12px;
line-height: 30px;
}
+.step-links {
+ padding-top: 0;
+ padding-bottom: 0;
+ margin-right: 10em;
+ text-align: right;
+ }
+.step-links button.next {
+ padding-top: 10px;
+ }
+.step-links .prev {
+ margin-right: 2em;
+ }
.lazr-multiline-edit .yui3-ieditor {
padding-right: 0;
}
=== modified file 'lib/lp/app/browser/lazrjs.py'
--- lib/lp/app/browser/lazrjs.py 2012-02-17 02:36:05 +0000
+++ lib/lp/app/browser/lazrjs.py 2012-02-21 10:48:21 +0000
@@ -36,7 +36,10 @@
)
from lp.app.browser.stringformatter import FormattersAPI
-from lp.app.browser.vocabulary import get_person_picker_entry_metadata
+from lp.app.browser.vocabulary import (
+ get_person_picker_entry_metadata,
+ vocabulary_filters,
+ )
from lp.services.features import getFeatureFlag
from lp.services.propertycache import cachedproperty
from lp.services.webapp.interfaces import ILaunchBag
@@ -327,24 +330,7 @@
@cachedproperty
def vocabulary_filters(self):
- # Only IHugeVocabulary's have filters.
- if not IHugeVocabulary.providedBy(self.vocabulary):
- return []
- supported_filters = self.vocabulary.supportedFilters()
- # If we have no filters or just the ALL filter, then no filtering
- # support is required.
- filters = []
- if (len(supported_filters) == 0 or
- (len(supported_filters) == 1
- and supported_filters[0].name == 'ALL')):
- return filters
- for filter in supported_filters:
- filters.append({
- 'name': filter.name,
- 'title': filter.title,
- 'description': filter.description,
- })
- return filters
+ return vocabulary_filters(self.vocabulary)
@property
def show_search_box(self):
=== modified file 'lib/lp/app/browser/vocabulary.py'
--- lib/lp/app/browser/vocabulary.py 2012-01-01 02:58:52 +0000
+++ lib/lp/app/browser/vocabulary.py 2012-02-21 10:48:21 +0000
@@ -9,6 +9,7 @@
'HugeVocabularyJSONView',
'IPickerEntrySource',
'get_person_picker_entry_metadata',
+ 'vocabulary_filters',
]
from itertools import izip
@@ -492,3 +493,24 @@
self.request.response.setHeader('Content-type', 'application/json')
return simplejson.dumps(dict(total_size=total_size, entries=result))
+
+
+def vocabulary_filters(vocabulary):
+ # Only IHugeVocabulary's have filters.
+ if not IHugeVocabulary.providedBy(vocabulary):
+ return []
+ supported_filters = vocabulary.supportedFilters()
+ # If we have no filters or just the ALL filter, then no filtering
+ # support is required.
+ filters = []
+ if (len(supported_filters) == 0 or
+ (len(supported_filters) == 1
+ and supported_filters[0].name == 'ALL')):
+ return filters
+ for filter in supported_filters:
+ filters.append({
+ 'name': filter.name,
+ 'title': filter.title,
+ 'description': filter.description,
+ })
+ return filters
=== modified file 'lib/lp/app/enums.py'
--- lib/lp/app/enums.py 2012-02-14 23:01:04 +0000
+++ lib/lp/app/enums.py 2012-02-21 10:48:21 +0000
@@ -5,6 +5,7 @@
__metaclass__ = type
__all__ = [
+ 'InformationVisibilityPolicy',
'ServiceUsage',
'service_uses_launchpad',
]
=== modified file 'lib/lp/app/javascript/picker/picker.js'
--- lib/lp/app/javascript/picker/picker.js 2012-01-30 03:27:51 +0000
+++ lib/lp/app/javascript/picker/picker.js 2012-02-21 10:48:21 +0000
@@ -641,7 +641,16 @@
this._syncFilterUI();
}
}
+ },
+ /**
+ * Update the progress UI based on the results attribute.
+ *
+ * @method _syncProgressUI
+ * @protected
+ */
+ _syncProgressUI: function() {
+ var results = this.get(RESULTS);
if (results.length) {
// Set PrettyOverlay's green progress bar to 100%.
this.set('progress', 100);
@@ -835,6 +844,7 @@
// clear the search mode.
this.after('resultsChange', function (e) {
this._syncResultsUI();
+ this._syncProgressUI();
this.set(SEARCH_MODE, false);
}, this);
@@ -888,6 +898,7 @@
*/
_syncUIPicker: function() {
this._syncResultsUI();
+ this._syncProgressUI();
this._syncSearchModeUI();
this._syncBatchesUI();
this._syncSelectedBatchUI();
=== modified file 'lib/lp/app/javascript/picker/picker_patcher.js'
--- lib/lp/app/javascript/picker/picker_patcher.js 2012-02-01 23:40:45 +0000
+++ lib/lp/app/javascript/picker/picker_patcher.js 2012-02-21 10:48:21 +0000
@@ -417,7 +417,6 @@
var header = 'Choose an item.';
var step_title = "Enter search terms";
- var show_search_box = true;
var picker_type = "default";
if (config !== undefined) {
if (config.header !== undefined) {
@@ -428,10 +427,6 @@
step_title = config.step_title;
}
- if (config.show_search_box !== undefined) {
- show_search_box = config.show_search_box;
- }
-
if (config.picker_type !== undefined) {
picker_type = config.picker_type;
}
@@ -439,12 +434,6 @@
config = {};
}
- if (typeof vocabulary !== 'string' && typeof vocabulary !== 'object') {
- throw new TypeError(
- "vocabulary argument for Y.lp.picker.create() must be a " +
- "string or associative array: " + vocabulary);
- }
-
var new_config = Y.merge(config, {
associated_field_id: associated_field_id,
align: {
@@ -466,6 +455,24 @@
} else {
picker = new Y.lazr.picker.Picker(new_config);
}
+ namespace.setup_vocab_picker(picker, vocabulary, config);
+ return picker;
+};
+
+namespace.setup_vocab_picker = function (picker, vocabulary, config) {
+
+ var show_search_box = true;
+ if (config !== undefined) {
+ if (config.show_search_box !== undefined) {
+ show_search_box = config.show_search_box;
+ }
+ }
+
+ if (typeof vocabulary !== 'string' && typeof vocabulary !== 'object') {
+ throw new TypeError(
+ "vocabulary argument for Y.lp.picker.setup_vocab_picker() " +
+ "must be a string or associative array: " + vocabulary);
+ }
// We don't want the default save to fire since this hides
// the form. We want to do this ourselves after any validation has had a
@@ -514,7 +521,7 @@
);
});
- picker.subscribe('save', function (e) {
+ var save_handler = function(e) {
Y.log('Got save event.');
var picker_result = e.details[Y.lazr.picker.Picker.SAVE_RESULT];
user_has_searched = false;
@@ -523,7 +530,8 @@
config.save(picker_result);
}
picker._defaultSave(e);
- });
+ };
+ picker.after(Y.lazr.picker.Picker.SAVE, save_handler);
picker.subscribe('cancel', function (e) {
Y.log('Got cancel event.');
=== modified file 'lib/lp/app/widgets/popup.py'
--- lib/lp/app/widgets/popup.py 2012-01-24 11:18:47 +0000
+++ lib/lp/app/widgets/popup.py 2012-02-21 10:48:21 +0000
@@ -19,10 +19,12 @@
from zope.schema.interfaces import IChoice
from lp.app.browser.stringformatter import FormattersAPI
-from lp.app.browser.vocabulary import get_person_picker_entry_metadata
+from lp.app.browser.vocabulary import (
+ get_person_picker_entry_metadata,
+ vocabulary_filters,
+ )
from lp.services.propertycache import cachedproperty
from lp.services.webapp import canonical_url
-from lp.services.webapp.vocabulary import IHugeVocabulary
class VocabularyPickerWidget(SingleDataHelper, ItemsWidgetBase):
@@ -168,24 +170,7 @@
"The %r.%s interface attribute doesn't have its "
"vocabulary specified."
% (choice.context, choice.__name__))
- # Only IHugeVocabulary's have filters.
- if not IHugeVocabulary.providedBy(choice.vocabulary):
- return []
- supported_filters = choice.vocabulary.supportedFilters()
- # If we have no filters or just the ALL filter, then no filtering
- # support is required.
- filters = []
- if (len(supported_filters) == 0 or
- (len(supported_filters) == 1
- and supported_filters[0].name == 'ALL')):
- return filters
- for filter in supported_filters:
- filters.append({
- 'name': filter.name,
- 'title': filter.title,
- 'description': filter.description,
- })
- return filters
+ return vocabulary_filters(choice.vocabulary)
@property
def vocabulary_name(self):
=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py 2012-02-17 02:53:38 +0000
+++ lib/lp/bugs/browser/bugtask.py 2012-02-21 10:48:21 +0000
@@ -136,6 +136,7 @@
ObjectImageDisplayAPI,
PersonFormatterAPI,
)
+from lp.app.browser.vocabulary import vocabulary_filters
from lp.app.enums import ServiceUsage
from lp.app.errors import (
NotFoundError,
@@ -1187,7 +1188,7 @@
else:
vocab_name = 'AllUserTeamsParticipation'
vocab = vocabulary_registry.get(None, vocab_name)
- return vocab_name, vocab.supportedFilters()
+ return vocab_name, vocab
class BugTaskBugWatchMixin:
@@ -4123,20 +4124,9 @@
def bugtask_config(self):
"""Configuration for the bugtask JS widgets on the row."""
- assignee_vocabulary, assignee_vocabulary_filters = (
+ assignee_vocabulary_name, assignee_vocabulary = (
get_assignee_vocabulary_info(self.context))
- # If we have no filters or just the ALL filter, then no filtering
- # support is required.
- filter_details = []
- if (len(assignee_vocabulary_filters) > 1 or
- (len(assignee_vocabulary_filters) == 1
- and assignee_vocabulary_filters[0].name != 'ALL')):
- for filter in assignee_vocabulary_filters:
- filter_details.append({
- 'name': filter.name,
- 'title': filter.title,
- 'description': filter.description,
- })
+ filter_details = vocabulary_filters(assignee_vocabulary)
# Display the search field only if the user can set any person
# or team
user = self.user
@@ -4154,7 +4144,7 @@
bug_title=cx.bug.title,
assignee_value=cx.assignee and cx.assignee.name,
assignee_is_team=cx.assignee and cx.assignee.is_team,
- assignee_vocabulary=assignee_vocabulary,
+ assignee_vocabulary=assignee_vocabulary_name,
assignee_vocabulary_filters=filter_details,
hide_assignee_team_selection=hide_assignee_team_selection,
user_can_unassign=cx.userCanUnassign(user),
=== modified file 'lib/lp/registry/browser/product.py'
--- lib/lp/registry/browser/product.py 2012-02-15 16:35:48 +0000
+++ lib/lp/registry/browser/product.py 2012-02-21 10:48:21 +0000
@@ -54,6 +54,7 @@
from lazr.delegates import delegates
from lazr.restful.interface import copy_field
import pytz
+import simplejson
from z3c.ptcompat import ViewPageTemplateFile
from zope.app.form import CustomWidgetFactory
from zope.app.form.browser import (
@@ -74,7 +75,9 @@
Bool,
Choice,
)
+from zope.schema.interfaces import IVocabulary
from zope.schema.vocabulary import (
+ getVocabularyRegistry,
SimpleTerm,
SimpleVocabulary,
)
@@ -108,7 +111,11 @@
format_link,
MenuAPI,
)
-from lp.app.enums import ServiceUsage
+from lp.app.browser.vocabulary import vocabulary_filters
+from lp.app.enums import (
+ InformationVisibilityPolicy,
+ ServiceUsage,
+ )
from lp.app.errors import NotFoundError
from lp.app.interfaces.headings import IEditableContextTitle
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -1558,7 +1565,7 @@
def updateContextFromData(self, data, context=None, notify_modified=True):
# private_bugs uses a mutator to check permissions, so it needs to
# be handled separately.
- if data.has_key('private_bugs'):
+ if 'private_bugs' in data:
self.context.setPrivateBugs(data['private_bugs'], self.user)
del data['private_bugs']
parent = super(ProductPrivateBugsMixin, self)
@@ -2417,3 +2424,39 @@
page_title = "Sharing"
label = "Sharing information"
+
+ @property
+ def access_policies(self):
+ result = []
+ for x, policy in enumerate(InformationVisibilityPolicy):
+ item = dict(
+ index=x,
+ value=policy.token,
+ title=policy.title,
+ description=policy.value.description
+ )
+ result.append(item)
+ return result
+
+ @cachedproperty
+ def sharing_vocabulary(self):
+ registry = getVocabularyRegistry()
+ return registry.get(
+ IVocabulary, 'ValidPersonOrTeam')
+
+ @cachedproperty
+ def sharing_vocabulary_filters(self):
+ return vocabulary_filters(self.sharing_vocabulary)
+
+ @property
+ def sharing_picker_config(self):
+ return dict(
+ access_policies=self.access_policies,
+ vocabulary='ValidPersonOrTeam',
+ vocabulary_filters=self.sharing_vocabulary_filters,
+ header='Grant access to %s'
+ % self.context.displayname)
+
+ @property
+ def json_sharing_picker_config(self):
+ return simplejson.dumps(self.sharing_picker_config)
=== modified file 'lib/lp/registry/browser/tests/test_product.py'
--- lib/lp/registry/browser/tests/test_product.py 2012-02-01 15:26:32 +0000
+++ lib/lp/registry/browser/tests/test_product.py 2012-02-21 10:48:21 +0000
@@ -8,6 +8,7 @@
import datetime
import pytz
+import simplejson
from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
@@ -403,3 +404,21 @@
content_disposition, browser.headers['Content-disposition'])
self.assertEqual(
'application/rdf+xml', browser.headers['Content-type'])
+
+
+class TestProductSharingView(TestCaseWithFactory):
+ """Test the ProductSharingView."""
+
+ layer = DatabaseFunctionalLayer
+
+ def test_picker_config(self):
+ # Test the config passed to the disclosure sharing picker.
+ product = self.factory.makeProduct()
+ view = create_view(product, name='+sharing')
+ picker_config = simplejson.loads(view.json_sharing_picker_config)
+ self.assertTrue('access_policies' in picker_config)
+ self.assertTrue('vocabulary_filters' in picker_config)
+ self.assertEqual(
+ 'Grant access to %s' % product.displayname,
+ picker_config['header'])
+ self.assertEqual('ValidPersonOrTeam', picker_config['vocabulary'])
=== added directory 'lib/lp/registry/javascript/disclosure'
=== added file 'lib/lp/registry/javascript/disclosure/disclosure_person_picker.js'
--- lib/lp/registry/javascript/disclosure/disclosure_person_picker.js 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/disclosure_person_picker.js 2012-02-21 10:48:21 +0000
@@ -0,0 +1,220 @@
+/* Copyright 2012 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Disclosure infrastructure.
+ *
+ * @module lp.registry.disclosure
+ */
+
+YUI.add('lp.registry.disclosure', function(Y) {
+
+var namespace = Y.namespace('lp.registry.disclosure');
+
+var DisclosurePicker;
+DisclosurePicker = function() {
+ DisclosurePicker.superclass.constructor.apply(this, arguments);
+
+};
+
+DisclosurePicker.ATTRS = {
+ /**
+ * The value, in percentage, of the progress bar.
+ *
+ * @attribute access_policies
+ * @type Object
+ * @default []
+ */
+ access_policies: {
+ value: []
+ }
+};
+
+
+Y.extend(DisclosurePicker, Y.lazr.picker.Picker, {
+
+ // Override for testing
+ _anim_duratrion: 1,
+
+ initializer: function(config) {
+ DisclosurePicker.superclass.initializer.apply(this, arguments);
+ var access_policies = [];
+ if (config !== undefined) {
+ if (config.access_policies !== undefined) {
+ access_policies = config.access_policies;
+ }
+ if (config.anim_duratrion !== undefined) {
+ this._anim_duratrion = config.anim_duratrion;
+ }
+ }
+ this.set('access_policies', access_policies);
+ var self = this;
+ this.subscribe('save', function (e) {
+ e.preventDefault();
+ // The step number indicates which picker step has just fired.
+ var step_nr = e.details[1];
+ if (!Y.Lang.isNumber(step_nr)) {
+ step_nr = 1;
+ }
+ var data = e.details[Y.lazr.picker.Picker.SAVE_RESULT];
+ switch(step_nr) {
+ case 1:
+ self._display_step_two(data);
+ break;
+ case 2:
+ self._publish_result(data);
+ break;
+ default:
+ return;
+ }
+ });
+ },
+
+ _fade_in: function(content_node, old_content) {
+ content_node.removeClass('unseen');
+ if (old_content === null) {
+ return;
+ }
+ old_content.addClass('unseen');
+ if (this._anim_duratrion === 0) {
+ return;
+ }
+ content_node.addClass('transparent');
+ content_node.setStyle('opacity', 0);
+ var fade_in = new Y.Anim({
+ node: content_node,
+ to: {opacity: 1},
+ duration: this._anim_duratrion
+ });
+ fade_in.run();
+ },
+
+ _display_step_one: function() {
+ this.set('steptitle', 'Search for someone with whom to share');
+ this.set('progress', 50);
+ var contentBox = this.get('contentBox');
+ var step_one_content = contentBox.one('.yui3-widget-bd');
+ var step_two_content = contentBox.one('.picker-content-two');
+ this._fade_in(step_one_content, step_two_content);
+ },
+
+ _display_step_two: function(data) {
+ var title = Y.Lang.substitute('Select access policy for {name}',
+ {name: data.title});
+ this.set('steptitle', title);
+ this.set('progress', 75);
+ var contentBox = this.get('contentBox');
+ var step_one_content = contentBox.one('.yui3-widget-bd');
+ var step_two_content = contentBox.one('.picker-content-two');
+ if (step_two_content === null) {
+ var step_two_html = [
+ '<div class="picker-content-two transparent">',
+ '<div class="step-links">',
+ '<a class="prev js-action" href="#">Back</a>',
+ '<button class="next lazr-pos lazr-btn"></button>',
+ '<a class="next js-action" href="#">Select</a>',
+ '</div></div>'
+ ].join(' ');
+ step_two_content = Y.Node.create(step_two_html);
+ var self = this;
+ step_two_content.one('a.prev').on('click', function(e) {
+ e.preventDefault();
+ self._display_step_one();
+ });
+ step_two_content.all('.next').on('click', function(e) {
+ e.preventDefault();
+ self.fire('save', data, 2);
+ });
+ step_two_content.one('div.step-links')
+ .insert(self._make_policy_selector(), 'before');
+ step_one_content.insert(step_two_content, 'after');
+ step_two_content.one('input[id=field.visibility.0]')
+ .set('checked', 'checked');
+ }
+ this._fade_in(step_two_content, step_one_content);
+ },
+
+ _publish_result: function(data) {
+ // Determine the chosen access policy type. data already contains the
+ // selected person due to the base picker behaviour.
+ var contentBox = this.get('contentBox');
+ var selected_access_policy;
+ contentBox.all('input[name=field.visibility]')
+ .each(function(node) {
+ if (node.get('checked')) {
+ selected_access_policy = node.get('value');
+ }
+ });
+ data.access_policy = selected_access_policy;
+ // Publish the result with step_nr 0 to indicate we have finished.
+ this.fire('save', data, 0);
+ },
+
+ _make_policy_selector: function() {
+ // The policy selector is a set of radio buttons.
+ var html = Y.lp.mustache.to_html([
+ '<div style="margin-top: 0.75em">',
+ '<table class="radio-button-widget"><tbody>',
+ '{{#policies}}',
+ ' <tr>',
+ ' <td rowspan="2"><input type="radio"',
+ ' value="{{value}}"',
+ ' name="field.visibility"',
+ ' id="field.visibility.{{index}}"',
+ ' class="radioType">',
+ ' </td>',
+ ' <td><label for="field.visibility.{{index}}">',
+ ' <span class="accessPolicy{{value}}">{{title}}',
+ ' </span></label>',
+ ' </td>',
+ ' </tr>',
+ ' <tr>',
+ ' <td class="formHelp">',
+ ' {{description}}',
+ ' </td>',
+ ' </tr>',
+ '{{/policies}}',
+ '</tbody></table></div>'
+ ].join(''), {
+ policies: this.get('access_policies')
+ });
+ return Y.Node.create(html);
+ },
+
+ _syncProgressUI: function() {
+ // The base picker behaviour is to set the progress bar to 100% once
+ // the search results are displayed. We want to control the progress
+ // bar as the user steps through the picker screens.
+ },
+
+ _clear: function() {
+ var contentBox = this.get('contentBox');
+ var first_button = contentBox.one('input[id=field.visibility.0]');
+ if (first_button !== null) {
+ first_button.set('checked', 'checked');
+ }
+ this.constructor.superclass._clear.call(this);
+ },
+
+ hide: function() {
+ this.get('boundingBox').setStyle('display', 'none');
+ var contentBox = this.get('contentBox');
+ var step_two_content = contentBox.one('.picker-content-two');
+ if (step_two_content !== null) {
+ step_two_content.remove(true);
+ }
+ this.constructor.superclass.hide.call(this);
+ },
+
+ show: function() {
+ this._display_step_one();
+ this.get('boundingBox').setStyle('display', 'block');
+ this.constructor.superclass.show.call(this);
+ }
+
+});
+
+DisclosurePicker.NAME = 'disclosure_picker';
+namespace.DisclosurePicker = DisclosurePicker;
+
+}, "0.1", { "requires": ['node', 'lp.mustache', 'lazr.picker'] });
+
=== added file 'lib/lp/registry/javascript/disclosure/product_sharing.js'
--- lib/lp/registry/javascript/disclosure/product_sharing.js 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/product_sharing.js 2012-02-21 10:48:21 +0000
@@ -0,0 +1,62 @@
+/* Copyright 2012 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Disclosure infrastructure.
+ *
+ * @module lp.registry.disclosure
+ */
+
+YUI.add('lp.registry.disclosure.sharing', function(Y) {
+
+var namespace = Y.namespace('lp.registry.disclosure.sharing');
+
+var disclosure_picker = null;
+
+var save_sharing_selection = function(result) {
+ Y.log(result.access_policy);
+ Y.log(result.api_uri);
+};
+
+var setup_product_sharing = function(config) {
+
+ if (disclosure_picker === null) {
+ var vocab = 'ValidPersonOrTeam';
+ var header = 'Grant access to project artifacts.';
+ if (config !== undefined) {
+ if (config.header !== undefined) {
+ header = config.header;
+ }
+ } else {
+ config = {};
+ }
+ var new_config = Y.merge(config, {
+ align: {
+ points: [Y.WidgetPositionAlign.CC,
+ Y.WidgetPositionAlign.CC]
+ },
+ progressbar: true,
+ progress: 50,
+ headerContent: "<h2>" + header + "</h2>",
+ zIndex: 1000,
+ visible: false,
+ save: save_sharing_selection
+ });
+ disclosure_picker =
+ new Y.lp.registry.disclosure.DisclosurePicker(new_config);
+ Y.lp.app.picker.setup_vocab_picker(
+ disclosure_picker, vocab, new_config);
+ }
+
+ var share_link = Y.one('#add-observer-link');
+ share_link.on('click', function(e) {
+ e.preventDefault();
+ disclosure_picker.show();
+ });
+};
+
+namespace.setup_product_sharing = setup_product_sharing;
+
+}, "0.1", { "requires": [
+ 'node', 'lp.mustache', 'lazr.picker', 'lp.app.picker',
+ 'lp.registry.disclosure'] });
+
=== added directory 'lib/lp/registry/javascript/disclosure/tests'
=== added file 'lib/lp/registry/javascript/disclosure/tests/test_disclosure_person_picker.html'
--- lib/lp/registry/javascript/disclosure/tests/test_disclosure_person_picker.html 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/tests/test_disclosure_person_picker.html 2012-02-21 10:48:21 +0000
@@ -0,0 +1,68 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+ "http://www.w3.org/TR/html4/strict.dtd">
+<!--
+Copyright 2012 Canonical Ltd. This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+ <head>
+ <title>Disclosure Person Picker Tests</title>
+
+ <!-- YUI and test setup -->
+ <script type="text/javascript"
+ src="../../../../../../build/js/yui/yui/yui.js">
+ </script>
+ <link rel="stylesheet"
+ href="../../../../../../build/js/yui/console/assets/console-core.css" />
+ <link rel="stylesheet"
+ href="../../../../../../build/js/yui/console/assets/skins/sam/console.css" />
+ <link rel="stylesheet"
+ href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
+
+ <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+
+ <!-- Dependencies -->
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/client.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/lp.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/mustache.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/activator/activator.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/picker/picker.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/picker/person_picker.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/picker/picker_patcher.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/expander.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/anim/anim.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/effects/effects.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/lazr/lazr.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/extras/extras.js"></script>
+
+ <!-- The module under test. -->
+ <script type="text/javascript" src="../disclosure_person_picker.js"></script>
+
+ <!-- The test suite -->
+ <script type="text/javascript" src="test_disclosure_person_picker.js"></script>
+
+ </head>
+ <body class="yui3-skin-sam">
+ <ul id="suites">
+ <li>lp.registry.disclosure.test</li>
+ </ul>
+ </body>
+</html>
=== added file 'lib/lp/registry/javascript/disclosure/tests/test_disclosure_person_picker.js'
--- lib/lp/registry/javascript/disclosure/tests/test_disclosure_person_picker.js 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/javascript/disclosure/tests/test_disclosure_person_picker.js 2012-02-21 10:48:21 +0000
@@ -0,0 +1,176 @@
+/* Copyright (c) 2012, Canonical Ltd. All rights reserved. */
+
+YUI.add('lp.registry.disclosure.test', function (Y) {
+
+ var tests = Y.namespace('lp.registry.disclosure.test');
+ tests.suite = new Y.Test.Suite('lp.registry.disclosure Tests');
+
+ tests.suite.add(new Y.Test.Case({
+ name: 'lp.registry.disclosure_tests',
+
+ setUp: function () {
+ this.vocabulary = [
+ {"value": "fred", "title": "Fred", "css": "sprite-person",
+ "description": "fred@xxxxxxxxxxx", "api_uri": "~/fred",
+ "metadata": "person"},
+ {"value": "frieda", "title": "Frieda", "css": "sprite-team",
+ "description": "frieda@xxxxxxxxxxx", "api_uri": "~/frieda",
+ "metadata": "team"}
+ ];
+ this.access_policies = [
+ {index: '0', value: 'P1', title: 'Policy 1',
+ description: 'Policy 1 description'},
+ {index: '1', value: 'P2', title: 'Policy 2',
+ description: 'Policy 2 description'},
+ {index: '2', value: 'P3', title: 'Policy 3',
+ description: 'Policy 3 description'}];
+ },
+
+ tearDown: function () {
+ if (Y.Lang.isObject(this.picker)) {
+ this.cleanup_widget(this.picker);
+ }
+ },
+
+ /* Helper function to clean up a dynamically added widget instance. */
+ cleanup_widget: function(widget) {
+ // Nuke the boundingBox, but only if we've touched the DOM.
+ if (widget.get('rendered')) {
+ var bb = widget.get('boundingBox');
+ bb.get('parentNode').removeChild(bb);
+ }
+ // Kill the widget itself.
+ widget.destroy();
+ },
+
+ _create_picker: function(overrides) {
+ var config = {
+ anim_duratrion: 0,
+ progressbar: true,
+ progress: 50,
+ headerContent: "<h2>Grant access</h2>",
+ steptitle: "Search for someone with whom to share",
+ zIndex: 1000,
+ visible: false,
+ access_policies: this.access_policies
+ };
+ if (overrides !== undefined) {
+ config = Y.merge(config, overrides);
+ }
+ var picker =
+ new Y.lp.registry.disclosure.DisclosurePicker(config);
+ Y.lp.app.picker.setup_vocab_picker(picker, "TestVocab", config);
+ return picker;
+ },
+
+ test_library_exists: function () {
+ Y.Assert.isObject(Y.lp.registry.disclosure,
+ "We should be able to locate the " +
+ "lp.registry.disclosure module");
+ },
+
+ test_picker_can_be_instantiated: function() {
+ this.picker = this._create_picker();
+ Y.Assert.isInstanceOf(
+ Y.lp.registry.disclosure.DisclosurePicker, this.picker,
+ "Picker failed to be instantiated");
+ },
+
+ // Test that the picker initially displays a normal search and select
+ // facility and transitions to step two when a result is selected.
+ test_first_step: function() {
+ this.picker = this._create_picker();
+ // Select a person to trigger transition to next step.
+ this.picker.set('results', this.vocabulary);
+ this.picker.render();
+ var cb = this.picker.get('contentBox');
+ var steptitle = cb.one('.contains-steptitle h2').getContent();
+ Y.Assert.areEqual(
+ 'Search for someone with whom to share', steptitle);
+ this.picker.get('boundingBox').one(
+ '.yui3-picker-results li:nth-child(1)').simulate('click');
+ // There should be no saved value at this stage.
+ Y.Assert.isUndefined(this.saved_picker_value);
+
+ // The progress should be 75%
+ Y.Assert.areEqual(75, this.picker.get('progress'));
+ // The first step ui should be hidden.
+ Y.Assert.isTrue(cb.one('.yui3-widget-bd').hasClass('unseen'));
+ // The step title should be updated according to the selected
+ // person.
+ steptitle = cb.one('.contains-steptitle h2').getContent();
+ Y.Assert.areEqual('Select access policy for Fred', steptitle);
+ // The second step ui should be visible.
+ var step_two_content = cb.one('.picker-content-two');
+ Y.Assert.isFalse(step_two_content.hasClass('unseen'));
+ // The second step ui should contain input buttons for each access
+ // policy type.
+ Y.Array.each(this.access_policies, function(policy) {
+ var rb = step_two_content.one(
+ 'input[value=' + policy.value + ']');
+ Y.Assert.isNotNull(rb);
+ });
+ // The first policy button should be selected.
+ Y.Assert.isTrue(step_two_content
+ .one('input[id=field.visibility.0]').get('checked'));
+ // There should be a link back to previous step.
+ Y.Assert.isNotNull(step_two_content.one('a.prev'));
+ // There should be a button and link to finalise the selection.
+ Y.Assert.isNotNull(step_two_content.one('a.next'));
+ Y.Assert.isNotNull(step_two_content.one('button.next'));
+ },
+
+ // Test that the back link goes back to step one when step two is
+ // active.
+ test_second_step_back_link: function() {
+ this.picker = this._create_picker();
+ // Select a person to trigger transition to next step.
+ this.picker.set('results', this.vocabulary);
+ this.picker.render();
+ this.picker.get('boundingBox').one(
+ '.yui3-picker-results li:nth-child(1)').simulate('click');
+ var cb = this.picker.get('contentBox');
+ var step_two_content = cb.one('.picker-content-two');
+ var back_link = step_two_content.one('a.prev');
+ back_link.simulate('click');
+ // The progress should be 50%
+ Y.Assert.areEqual(50, this.picker.get('progress'));
+ // The first step ui should be visible.
+ Y.Assert.isFalse(cb.one('.yui3-widget-bd').hasClass('unseen'));
+ // The step title should be updated.
+ var steptitle = cb.one('.contains-steptitle h2').getContent();
+ Y.Assert.areEqual(
+ 'Search for someone with whom to share', steptitle);
+ // The second step ui should be hidden.
+ Y.Assert.isTrue(step_two_content.hasClass('unseen'));
+ },
+
+ // Test that a selection made in step two is correctly passed to the
+ // specified save function.
+ test_second_step_final_selection: function() {
+ var selected_result;
+ this.picker = this._create_picker(
+ {
+ save: function(result) {
+ selected_result = result;
+ }
+ }
+ );
+ // Select a person to trigger transition to next step.
+ this.picker.set('results', this.vocabulary);
+ this.picker.render();
+ this.picker.get('boundingBox').one(
+ '.yui3-picker-results li:nth-child(1)').simulate('click');
+ var cb = this.picker.get('contentBox');
+ var step_two_content = cb.one('.picker-content-two');
+ // Select an access policy.
+ step_two_content.one('input[value=P2]').simulate('click');
+ var select_link = step_two_content.one('a.next');
+ select_link.simulate('click');
+ Y.Assert.areEqual('P2', selected_result.access_policy);
+ Y.Assert.areEqual('~/fred', selected_result.api_uri);
+ }
+ }));
+
+}, '0.1', {'requires': ['test', 'console', 'event', 'node-event-simulate',
+ 'lp.app.picker', 'lp.registry.disclosure']});
=== modified file 'lib/lp/registry/templates/product-sharing.pt'
--- lib/lp/registry/templates/product-sharing.pt 2012-02-16 22:18:48 +0000
+++ lib/lp/registry/templates/product-sharing.pt 2012-02-21 10:48:21 +0000
@@ -7,6 +7,20 @@
i18n:domain="launchpad"
>
+<head>
+ <metal:block fill-slot="head_epilogue">
+ <script tal:content="structure string:
+ LPJS.use('base', 'node', 'event',
+ 'lp.registry.disclosure.sharing',
+ function(Y) {
+ Y.on('domready', function() {
+ var config = ${view/json_sharing_picker_config}
+ Y.lp.registry.disclosure.sharing.setup_product_sharing(config);
+ });
+ });
+ "/>
+ </metal:block>
+</head>
<body>
<div metal:fill-slot="main">
<p>
@@ -33,7 +47,10 @@
flux. Once they settle down, the view needs to populate some methods to
set the numbers in this portlet.
</tal:comment>
- <div id="portlet-disclosure-summary" class="first portlet">
+ <div id="portlet-disclosure-links" class="first portlet">
+ <a id='add-observer-link' class='sprite add js-action' href="#">Share</a>
+ </div>
+ <div id="portlet-disclosure-summary" class="portlet">
<p>
Permavirgin is shared with <a href="#">0</a> users:
</p>