Merge lp:~deryck/launchpad/field-visibility-persistence-891780 into lp:launchpad

Proposed by Deryck Hodge
Status: Merged
Approved by: Deryck Hodge
Approved revision: no longer in the source branch.
Merged at revision: 14367
Proposed branch: lp:~deryck/launchpad/field-visibility-persistence-891780
Merge into: lp:launchpad
Diff against target: 396 lines (+228/-8)
4 files modified
lib/lp/bugs/browser/bugtask.py (+36/-2)
lib/lp/bugs/browser/tests/test_bugtask.py (+27/-2)
lib/lp/bugs/javascript/buglisting_utils.js (+70/-2)
lib/lp/bugs/javascript/tests/test_buglisting_utils.js (+95/-2)
To merge this branch: bzr merge lp:~deryck/launchpad/field-visibility-persistence-891780
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Review via email: mp+83037@code.launchpad.net

Commit message

[r=gmb][bug=891780] Set a cookie that CustomBugListings can use to update field_visibility of a bug list.

Description of the change

Our new feature CustomBugListings needs to honor changes to field visibility in the listings across page loads. A simple way to achieve this is to set a cookie that stores those prefs. This branch makes a few changes to make that happen:

 * js code is updated to set/delete cookies
   based on submitting a form overlay
 * new tests are written for js to confirm this
 * view is updated to check for existence of cookie
   and update field_visibility accordingly
 * view also has a test added

Robert had some concerns in the bug about using cookies, which we discussed more offline. His concerns were that:

 * another cookie might cause request size to be too large
 * session cookie might be better than adding a new cookie

I've checked on request size and we're way safe. Nothing to worry about there.

I rejected the session cookie idea initially because I was only reading and writing from js, and js code shouldn't be able to read the session cookie as plain text. Or be able to reverse the hash. I didn't think we needed to understand the cookie on the server side because I thought we hid the first batch we render behind a noscript tag and rendered everything else client side. We do indeed use the first batch rendered on page load, so now the view is involved. I'm happy to revisit this before we're done-done and work out an API that the client can use to submit stuff to the session cookie if this is really required.

I'm still not sure I like this idea, though. Paranoia makes me fear anything other than the server adding something to the session cookie, and I'm also not sure what we gain for that beyond consolidating on a single cookie. There is clearly value in keeping cookie count low, so I can be persuaded, but generally, I think what I have here works fine. I recognize I could be tending toward stubborn old-man refusal here, so I welcome correction if so.

If people feel strongly about going through our session cookie, I'd prefer to land this as it is, assuming the code is okay, and file an XXX to fix this to use our session cookie before we're off the feature.

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

Hi Deryck,

Nice branch; good work! I've got a few comments but nothing earth
shattering.

[1]

9 + user = getUtility(ILaunchBag).user

Without looking at the context in more detail I can't be certain, but
typically we should be preferring self.request.user over using the
LaunchBag.

[2]

29 + """Set field_visibility for the page load.
30 +
31 +
32 + If a cookie of the form $USER-buglist-fields is found,

Looks like you have an extra line of blankness here.

[3]

47 + if cookie is not None:
48 + for item in cookie.split('&'):
49 + field, value = item.split('=')
50 + if value == 'true':
51 + value = True
52 + else:
53 + value = False
54 + fields_from_cookie[field] = value
55 + self.field_visibility = fields_from_cookie

You could save yourself some lines here by using urlparse.parse_qsl,
which returns (key, value) tuples, thus:

    for field, value in urlparse.parse_qsl(cookie):
        # We only record True or False for field values, so we only care
        # about whether the value from the cookie is 'true'.
        fields_from_cookie[field] = (value == 'true')
    self.field_visibility = fields_from_cookie

[4]

138 + /**
139 + * Helper method to always get the same cookie name, based
140 + * on user name.
141 + *
142 + * @method getCookieName
143 + */
144 + getCookieName: function() {
145 + var user;
146 + if (Y.Lang.isValue(window.LP) && Y.Lang.isValue(LP.links)) {
147 + var me = LP.links.me;
148 + user = me.replace('/~', '');
149 + } else {
150 + user = 'anon';
151 + }
152 + return user + '-buglist-fields';
153 + },

Rather than doing this twice - once in the View and once in the JS - it
would make more sense to do it in the view and then bung it in the page
JS cache so that the JS can just use the value that's already been
calculated.

review: Approve (code)
Revision history for this message
Robert Collins (lifeless) wrote :

@Deryck - using the session cookie to control this, the *server* would
still be setting it, always. So I'm not sure what you are worried
about.

Revision history for this message
Deryck Hodge (deryck) wrote :

The server would set whatever the js code passed in, though. And yeah, maybe it's not worth worrying about. It just feels wrong to me.

I'm not opposed to having a single cookie that client code can write to via an API; just not sure if it should be the same one as the session cookie. And if it's not the session one, then it feels the long way around to have an API to set a cookie. :)

Again, I'll concede I may be overly paranoid here.

Revision history for this message
Deryck Hodge (deryck) wrote :

Also, thanks for the review, gmb! I'll make the changes you suggested. All good ones there.

Revision history for this message
Robert Collins (lifeless) wrote :

On Wed, Nov 23, 2011 at 8:09 AM, Deryck Hodge
<email address hidden> wrote:
> The server would set whatever the js code passed in, though.  And yeah, maybe it's not worth worrying about.  It just feels wrong to me.

It doesn't need to do that - you can validate the information really
easily. Or you can say that things like 'client.*' are opaque to the
server (but this defeats the whole reason here, so you will want it
validated).

Revision history for this message
Deryck Hodge (deryck) wrote :

Sure, we can validate. I didn't mean to suggest we couldn't. I still feel hesitation about sticking some-what arbitrary data into the same object we use for authentication, but I won't be so stubborn to not do this if everyone really wants it.

And really, I'm warming to the idea the more I think about it. I might even come all the way around in another day or two, after I get all this work pending from me landed.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/bugtask.py'
2--- lib/lp/bugs/browser/bugtask.py 2011-11-22 11:26:38 +0000
3+++ lib/lp/bugs/browser/bugtask.py 2011-11-22 20:20:30 +0000
4@@ -2267,7 +2267,8 @@
5 # rules to a mixin so that MilestoneView and others can use it.
6 self.request = request
7 self.target_context = target_context
8- self.field_visibility = {
9+ self.user = getUtility(ILaunchBag).user
10+ self.field_visibility_defaults = {
11 'show_age': False,
12 'show_assignee': False,
13 'show_bugtarget': True,
14@@ -2281,7 +2282,8 @@
15 'show_tags': False,
16 'show_title': True,
17 }
18- self.field_visibility_defaults = self.field_visibility
19+ self.field_visibility = None
20+ self._setFieldVisibility()
21 TableBatchNavigator.__init__(
22 self, tasks, request, columns_to_show=columns_to_show, size=size)
23
24@@ -2290,6 +2292,37 @@
25 return getUtility(IBugTaskSet).getBugTaskBadgeProperties(
26 self.currentBatch())
27
28+ def getCookieName(self):
29+ """Return the cookie name used in bug listings js code."""
30+ cookie_name_template = '%s-buglist-fields'
31+ cookie_name = ''
32+ if self.user is not None:
33+ cookie_name = cookie_name_template % self.user.name
34+ else:
35+ cookie_name = cookie_name_template % 'anon'
36+ return cookie_name
37+
38+ def _setFieldVisibility(self):
39+ """Set field_visibility for the page load.
40+
41+ If a cookie of the form $USER-buglist-fields is found,
42+ we set field_visibility from this cookie; otherwise,
43+ field_visibility will match the defaults.
44+ """
45+ cookie_name = self.getCookieName()
46+ cookie = self.request.cookies.get(cookie_name)
47+ fields_from_cookie = {}
48+ # "cookie" looks like a URL query string, so we split
49+ # on '&' to get items, and then split on '=' to get
50+ # field/value pairs.
51+ if cookie is not None:
52+ for field, value in urlparse.parse_qsl(cookie):
53+ # We only record True or False for field values.
54+ fields_from_cookie[field] = (value == 'true')
55+ self.field_visibility = fields_from_cookie
56+ else:
57+ self.field_visibility = self.field_visibility_defaults
58+
59 def _getListingItem(self, bugtask):
60 """Return a decorated bugtask for the bug listing."""
61 badge_property = self.bug_badge_properties[bugtask]
62@@ -2612,6 +2645,7 @@
63 batch_navigator.field_visibility)
64 cache.objects['field_visibility_defaults'] = (
65 batch_navigator.field_visibility_defaults)
66+ cache.objects['cbl_cookie_name'] = batch_navigator.getCookieName()
67
68 def _getBatchInfo(batch):
69 if batch is None:
70
71=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
72--- lib/lp/bugs/browser/tests/test_bugtask.py 2011-11-21 00:31:41 +0000
73+++ lib/lp/bugs/browser/tests/test_bugtask.py 2011-11-22 20:20:30 +0000
74@@ -1710,7 +1710,7 @@
75 'client-listing', True, attrs={'id': 'client-listing'})
76
77 def makeView(self, bugtask=None, size=None, memo=None, orderby=None,
78- forwards=True):
79+ forwards=True, cookie=None):
80 """Make a BugTaskSearchListingView.
81
82 :param bugtask: The task to use for searching.
83@@ -1734,7 +1734,7 @@
84 query_vars['direction'] = 'backwards'
85 query_string = urllib.urlencode(query_vars)
86 request = LaunchpadTestRequest(
87- QUERY_STRING=query_string, orderby=orderby)
88+ QUERY_STRING=query_string, orderby=orderby, HTTP_COOKIE=cookie)
89 if bugtask is None:
90 bugtask = self.factory.makeBugTask()
91 view = BugTaskSearchListingView(bugtask.target, request)
92@@ -1865,6 +1865,31 @@
93 field_visibility = cache.objects['field_visibility']
94 self.assertTrue(field_visibility['show_title'])
95
96+ def test_cache_cookie_name(self):
97+ """The cookie name should be in cache for js code access."""
98+ task = self.factory.makeBugTask()
99+ with self.dynamic_listings():
100+ view = self.makeView(task, memo=1, forwards=False, size=1)
101+ cache = IJSONRequestCache(view.request)
102+ cookie_name = cache.objects['cbl_cookie_name']
103+ self.assertEqual('anon-buglist-fields', cookie_name)
104+
105+ def test_cache_field_visibility_matches_cookie(self):
106+ """Cache contains cookie-matching values for field_visibiliy."""
107+ task = self.factory.makeBugTask()
108+ cookie = (
109+ 'anon-buglist-fields=show_age=true&show_reporter=true'
110+ '&show_id=true&show_bugtarget=true&show_title=true'
111+ '&show_milestone_name=true&show_last_updated=true'
112+ '&show_assignee=true&show_bug_heat=true&show_tags=true'
113+ '&show_importance=true&show_status=true')
114+ with self.dynamic_listings():
115+ view = self.makeView(
116+ task, memo=1, forwards=False, size=1, cookie=cookie)
117+ cache = IJSONRequestCache(view.request)
118+ field_visibility = cache.objects['field_visibility']
119+ self.assertTrue(field_visibility['show_tags'])
120+
121 def test_cache_field_visibility_defaults(self):
122 """Cache contains sane-looking field_visibility_defaults values."""
123 task = self.factory.makeBugTask()
124
125=== modified file 'lib/lp/bugs/javascript/buglisting_utils.js'
126--- lib/lp/bugs/javascript/buglisting_utils.js 2011-11-16 17:10:36 +0000
127+++ lib/lp/bugs/javascript/buglisting_utils.js 2011-11-22 20:20:30 +0000
128@@ -102,6 +102,26 @@
129 },
130
131 /**
132+ * The cookie name as set by the view.
133+ *
134+ * We get this value from the LP cache.
135+ *
136+ * @attribute cookie_name
137+ * @type String
138+ */
139+ cookie_name: {
140+ valueFn: function() {
141+ if (
142+ Y.Lang.isValue(window.LP) &&
143+ Y.Lang.isValue(LP.cache.cbl_cookie_name)) {
144+ return LP.cache.cbl_cookie_name;
145+ } else {
146+ return '';
147+ }
148+ }
149+ },
150+
151+ /**
152 * A reference to the form overlay used in the overlay.
153 *
154 * @attribute form
155@@ -183,6 +203,7 @@
156 link.on('click', function(e) {
157 var defaults = this.get('field_visibility_defaults');
158 this.updateFieldVisibilty(defaults, true);
159+ this.setCookie();
160 }, this);
161 return link;
162 },
163@@ -226,7 +247,10 @@
164 */
165 updateFieldVisibilty: function(fields, destroy_form) {
166 this.set(FIELD_VISIBILITY, fields);
167- this.get(FORM).hide();
168+ var form = this.get(FORM);
169+ if (Y.Lang.isValue(form)) {
170+ form.hide();
171+ }
172 // Destroy the form and rebuild it.
173 if (destroy_form === true) {
174 this.get(FORM).hide();
175@@ -261,6 +285,49 @@
176 }
177 }
178 this.updateFieldVisibilty(fields);
179+ this.setCookie(fields);
180+ },
181+
182+ /**
183+ * Update field_visibility based on fields stored
184+ * in cookies. This is used as a light-weight
185+ * page to page persistence mechanism.
186+ *
187+ * @method updateFromCookie
188+ */
189+ updateFromCookie: function() {
190+ var cookie_name = this.get('cookie_name');
191+ var cookie_fields = Y.Cookie.getSubs(cookie_name);
192+ if (Y.Lang.isValue(cookie_fields)) {
193+ // We get true/false back as strings from Y.Cookie,
194+ // so we have to convert them to booleans.
195+ Y.each(cookie_fields, function(val, key, obj) {
196+ if (val === 'true') {
197+ val = true;
198+ } else {
199+ val = false;
200+ }
201+ obj[key] = val;
202+ });
203+ this.updateFieldVisibilty(cookie_fields);
204+ }
205+ },
206+
207+ /**
208+ * Set the given value for the buglisting config cookie.
209+ * If config is not specified, the cookie will be cleared.
210+ *
211+ * @method setCookie
212+ */
213+ setCookie: function(config) {
214+ var cookie_name = this.get('cookie_name');
215+ if (Y.Lang.isValue(config)) {
216+ Y.Cookie.setSubs(cookie_name, config, {
217+ path: '/',
218+ expires: new Date('January 19, 2038')});
219+ } else {
220+ Y.Cookie.remove(cookie_name);
221+ }
222 },
223
224 /**
225@@ -270,6 +337,7 @@
226 * @method _extraRenderUI
227 */
228 _extraRenderUI: function() {
229+ this.updateFromCookie();
230 var form_content = this.buildFormContent();
231 var on_submit_callback = Y.bind(this.handleOverlaySubmit, this);
232 util_overlay = new Y.lazr.FormOverlay({
233@@ -311,4 +379,4 @@
234 var buglisting_utils = Y.namespace('lp.buglisting_utils');
235 buglisting_utils.BugListingConfigUtil = BugListingConfigUtil;
236
237-}, '0.1', {'requires': ['lp.configutils', 'lazr.formoverlay']});
238+}, '0.1', {'requires': ['cookie', 'lp.configutils', 'lazr.formoverlay']});
239
240=== modified file 'lib/lp/bugs/javascript/tests/test_buglisting_utils.js'
241--- lib/lp/bugs/javascript/tests/test_buglisting_utils.js 2011-11-16 16:59:30 +0000
242+++ lib/lp/bugs/javascript/tests/test_buglisting_utils.js 2011-11-22 20:20:30 +0000
243@@ -25,9 +25,15 @@
244 },
245
246 setUp: function() {
247+ // _setDoc is required for tests using cookies to pass.
248+ Y.Cookie._setDoc({cookie: ""});
249 // Simulate LP.cache.field_visibility which will be
250 // present in the actual page.
251 window.LP = {
252+ links: {
253+ me: '/~foobar'
254+ },
255+
256 cache: {
257 field_visibility: {
258 show_title: true,
259@@ -57,15 +63,21 @@
260 show_reporter: false,
261 show_milestone_name: false,
262 show_tags: false
263- }
264+ },
265+
266+ cbl_cookie_name: 'foobar-buglist-fields'
267 }
268 };
269+ this.cookie_name = LP.cache.cbl_cookie_name;
270 },
271
272 tearDown: function() {
273 if (Y.Lang.isValue(this.list_util)) {
274 this.list_util.destroy();
275 }
276+ // Cleanup cookies.
277+ Y.Cookie.remove(this.cookie_name);
278+ Y.Cookie._setDoc(Y.config.doc);
279 },
280
281 /**
282@@ -112,6 +124,12 @@
283 this.list_util.render();
284 },
285
286+ test_cookie_name_attribute: function() {
287+ // cookie_name is taken from the cache.
288+ this.list_util = new Y.lp.buglisting_utils.BugListingConfigUtil();
289+ Assert.areEqual(this.cookie_name, this.list_util.get('cookie_name'));
290+ },
291+
292 test_field_visibility_defaults_readonly: function() {
293 // field_visibility_defaults is a readOnly attribute,
294 // so field_visibility_defaults will always return the same
295@@ -156,6 +174,30 @@
296 expected_config, this.list_util.get('field_visibility'));
297 },
298
299+ test_cookie_updates_field_visibility_config: function() {
300+ // If the $USER-buglist-fields cookie is present,
301+ // the widget will update field_visibility to these values.
302+ var expected_config = {
303+ show_title: true,
304+ show_id: true,
305+ show_importance: true,
306+ show_status: true,
307+ show_bug_heat: false,
308+ show_bugtarget: false,
309+ show_age: true,
310+ show_last_updated: true,
311+ show_assignee: false,
312+ show_reporter: false,
313+ show_milestone_name: false,
314+ show_tags: false
315+ };
316+ Y.Cookie.setSubs(this.cookie_name, expected_config);
317+ this.list_util = new Y.lp.buglisting_utils.BugListingConfigUtil();
318+ this.list_util.render();
319+ var actual_config = this.list_util.get('field_visibility');
320+ ObjectAssert.areEqual(expected_config, actual_config);
321+ },
322+
323 test_field_visibility_form_reference: function() {
324 // The form created from field_visibility defaults is referenced
325 // via BugListingConfigUtil.get('form')
326@@ -322,6 +364,38 @@
327 Assert.isTrue(event_fired);
328 },
329
330+ test_update_from_form_updates_cookie: function() {
331+ // When the form is submitted, a cookie is set to match
332+ // your preferred field_visibility.
333+ this.list_util = new Y.lp.buglisting_utils.BugListingConfigUtil();
334+ this.list_util.render();
335+ // Now poke at the page to set the cookie.
336+ var config = Y.one('.config');
337+ config.simulate('click');
338+ var show_bugtarget = Y.one('.show_bugtarget');
339+ show_bugtarget.simulate('click');
340+ var update = Y.one('.update-buglisting');
341+ update.simulate('click');
342+ var expected_config = {
343+ show_title: true,
344+ show_id: true,
345+ show_importance: true,
346+ show_status: true,
347+ show_bug_heat: true,
348+ show_bugtarget: false,
349+ show_age: false,
350+ show_last_updated: false,
351+ show_assignee: false,
352+ show_reporter: false,
353+ show_milestone_name: false,
354+ show_tags: false
355+ };
356+ var expected_cookie = Y.Cookie._createCookieHashString(
357+ expected_config);
358+ var actual_cookie = Y.Cookie.get(this.cookie_name);
359+ Assert.areEqual(expected_cookie, actual_cookie);
360+ },
361+
362 test_fields_visibility_form_reset: function() {
363 // Clicking "reset to defaults" on the form returns
364 // field_visibility to its default values.
365@@ -412,6 +486,25 @@
366 var actual_inputs = this.getActualInputData();
367 ArrayAssert.itemsAreSame(expected_names, actual_inputs[0]);
368 ArrayAssert.itemsAreSame(expected_checked, actual_inputs[1]);
369+ },
370+
371+ test_form_reset_removes_cookie: function() {
372+ // Clicking "reset to defaults" on the overlay will
373+ // remove any cookie added.
374+ this.list_util = new Y.lp.buglisting_utils.BugListingConfigUtil();
375+ this.list_util.render();
376+ // Now poke at the page to set the cookie.
377+ var config = Y.one('.config');
378+ config.simulate('click');
379+ var show_bugtarget = Y.one('.show_bugtarget');
380+ show_bugtarget.simulate('click');
381+ var update = Y.one('.update-buglisting');
382+ update.simulate('click');
383+ // Now reset from the form.
384+ config.simulate('click');
385+ Y.one('.reset-buglisting').simulate('click');
386+ var cookie = Y.Cookie.get(this.cookie_name);
387+ Assert.areSame('', cookie);
388 }
389
390 }));
391@@ -419,4 +512,4 @@
392 buglisting_utils.suite = suite;
393
394 }, '0.1', {'requires': [
395- 'test', 'node-event-simulate', 'lp.buglisting_utils']});
396+ 'test', 'node-event-simulate', 'cookie', 'lp.buglisting_utils']});