Merge lp:~wallyworld/launchpad/better-popup-show-widget-name into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 13138
Proposed branch: lp:~wallyworld/launchpad/better-popup-show-widget-name
Merge into: lp:launchpad
Diff against target: 335 lines (+123/-44)
4 files modified
lib/lp/app/browser/stringformatter.py (+45/-9)
lib/lp/app/doc/tales.txt (+17/-7)
lib/lp/app/widgets/popup.py (+6/-15)
lib/lp/app/widgets/tests/test_popup.py (+55/-13)
To merge this branch: bzr merge lp:~wallyworld/launchpad/better-popup-show-widget-name
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Benji York Pending
Review via email: mp+62113@code.launchpad.net

Commit message

[r=sinzui][bug=787389] Better generation of unique field names when they contain invalid html id characters

Description of the change

The fix for bug 777766 breaks existing Windmill tests and also makes new tests extremely hard to write because the show_widget_id attribute is no longer easily discoverable from the field name. The fix uses a base64 encoding to convert the field to non-human readable text.

The point of the fix was to remove invalid characters from the field name so that a valid HTML node id could be generated from the field name. This needs to be done another way so that developers can still relate the generated show_widget_id value back to the associated field name. Otherwise windmill tests which rely on this known mapping break.

The existing fix also didn't totally solve the problem. The input_id property of the widget which is used to construct the node for the text field didn't have the invalid characters removed.

== Implementation ==

The field names on a page/form are unique and the node names are based on these so that aspect is covered already. The invalid HTML characters are simply removed using a regexp substitution. In the process, an unnecessary property "suffix" on VocabularyPickerWidget was able to be removed.

So in summary, there are 2 nodes rendered for a VocabularyPickerWidget widget:
1. the text input field, which has id = widget.input_id
2. the Choose... anchor, which has id = "show-widget-" + widget.input_id

where widget.input_id is widget.name with the invalid chars stripped out.

== Tests ==

The TestVocabularyPickerWidget test case was modified to render a widget with an invalid field name. A custom metaclass for the form schema interface was used to do this.

Also, the failing Windmill test test_person_picker_widget was run successfully.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/app/widgets/popup.py
  lib/lp/app/widgets/tests/test_popup.py

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

We solved this problem a few years ago.

    from lp.app.browser.stringformatter import FormattersAPI
    valid_content_id_or_class = FormattersAPI(self.context.name).css_id()

You may have several widgets for a field and there are two ways to make a valid class or id:
    widget_id = '%s-edit' % valid_content_id_or_class
or
    widget_id = FormattersAPI('%s-edit' % valid_content_id_or_class).css_id()

review: Needs Fixing (code)
Revision history for this message
Ian Booth (wallyworld) wrote :

> We solved this problem a few years ago.
>
> from lp.app.browser.stringformatter import FormattersAPI
> valid_content_id_or_class = FormattersAPI(self.context.name).css_id()
>
> You may have several widgets for a field and there are two ways to make a
> valid class or id:
> widget_id = '%s-edit' % valid_content_id_or_class
> or
> widget_id = FormattersAPI('%s-edit' % valid_content_id_or_class).css_id()

The problem with the above approach is that it doesn't ensure uniqueness. If all invalid chars are simply converted to '-', then if we have fields named like so:
field.foo+
field.foo^

then css_id() will convert both to field.foo-

The approach used in the latest version of this mp is to extract the portion of the field name after the 'field.' bit and base64 encode that, retaining the 'field.' prefix. If this is considered valid, perhaps I should implement this approach in the FormattersAPI.css_id() method.

Thoughts?

Revision history for this message
Curtis Hovey (sinzui) wrote :

There were no cases in the past were we could have collisions are described. Most of the fields we use in control names are based on lp name fields. Is there an example of a field where this can happen?

If this really can happen, then I propose that the method take an optional arg (tales does not need this) that appends the base64 encoding so that we have a human-readable fragment that can be debugged.

Most importantly, I do not think we want two implementations. A smarter function may be all that we need.

Revision history for this message
Ian Booth (wallyworld) wrote :

On 26/05/11 00:08, Curtis Hovey wrote:
> There were no cases in the past were we could have collisions are described. Most of the fields we use in control names are based on lp name fields. Is there an example of a field where this can happen?
>

I made the above argument on irc but was assured there is a remote
chance a collision could happen. This page:

https://bugs.launchpad.net/ubuntu/+source/gtk+2.0/+bug/713922

has a dom node which is named after the project gtk+2.0 and the argument
made to me was that since such cases involve not just lp name fields
with statically unique names but names derived from user data, there is
a remote chance there may be a collision at some point in the future and
we shouldn't put in code which is ok now but may break later. I still
don't see how it could happen but deferred to those with more lp knowledge.

> If this really can happen, then I propose that the method take an optional arg (tales does not need this) that appends the base64 encoding so that we have a human-readable fragment that can be debugged.
>
> Most importantly, I do not think we want two implementations. A smarter function may be all that we need.

Agree with the above. My suggestion was that the existing formatter be
enhanced if deemed necessary.

Revision history for this message
Ian Booth (wallyworld) wrote :

I have modified the VocabularyPickerWidget to use FormattersAPI.css_id() to generate its input_id value.

The implementation of css_id has been changed:
1. '.' are considered valid id characters and will no longer be replaced by '-'. This is to allow ids like 'field.foo' to be rendered as is and not changed to 'field-foo'
2. If any invalid characters are found in the id and are replaced by '-', then the base64 encoding of the id name is appended to ensure uniqueness.

Unit tests have been added for css_id (there were none).

The only place that used css_id before this was the +review-licences page for projects. I checked that this page still works as expected.

Revision history for this message
Curtis Hovey (sinzui) wrote :

dots are not valid in CSS3 Ids, that is why YUI3 hates them and one of the reasons we created css_id().

Revision history for this message
Ian Booth (wallyworld) wrote :

And yet almost all of our pages have nodes with id and often name equal
to "field.xxx" and the only page which uses css_id() is the
+review-licences page. I'll revert the acceptance of "." but we should
consider fixing the rest of the system at some point.

On 26/05/11 14:33, Curtis Hovey wrote:
> dots are not valid in CSS3 Ids, that is why YUI3 hates them and one of the reasons we created css_id().

Revision history for this message
Curtis Hovey (sinzui) wrote :

On Thu, 2011-05-26 at 11:39 +0000, Ian Booth wrote:
> And yet almost all of our pages have nodes with id and often name
> equal
> to "field.xxx"

That is Zope.

> and the only page which uses css_id() is the
> +review-licences page. I'll revert the acceptance of "." but we should
> consider fixing the rest of the system at some point.

Many pages use the fmt:css-id.

--
__Curtis C. Hovey_________
http://launchpad.net/

Revision history for this message
Curtis Hovey (sinzui) wrote :

This looks good to land.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/browser/stringformatter.py'
2--- lib/lp/app/browser/stringformatter.py 2011-02-02 00:58:03 +0000
3+++ lib/lp/app/browser/stringformatter.py 2011-05-29 23:36:46 +0000
4@@ -2,6 +2,7 @@
5 # GNU Affero General Public License version 3 (see the file LICENSE).
6
7 """TALES formatter for strings."""
8+from base64 import urlsafe_b64encode
9
10 __metaclass__ = type
11 __all__ = [
12@@ -119,7 +120,7 @@
13 if nchars >= maxlen:
14 # stop if we've reached the maximum chunk size
15 break
16- if nchars >= minlen and not word[endpos-1].isalnum():
17+ if nchars >= minlen and not word[endpos - 1].isalnum():
18 # stop if we've reached the minimum chunk size and the last
19 # character wasn't alphanumeric.
20 break
21@@ -216,7 +217,7 @@
22 If there are opening parens in the url that are matched by closing
23 parens at the start of the trailer, those closing parens should be
24 part of the url."""
25- assert trailers != '', ( "Trailers must not be an empty string.")
26+ assert trailers != '', ("Trailers must not be an empty string.")
27 opencount = url.count('(')
28 closedcount = url.count(')')
29 missing = opencount - closedcount
30@@ -791,7 +792,7 @@
31 def shorten(self, maxlength):
32 """Use like tal:content="context/foo/fmt:shorten/60"."""
33 if len(self._stringtoformat) > maxlength:
34- return '%s...' % self._stringtoformat[:maxlength-3]
35+ return '%s...' % self._stringtoformat[:maxlength - 3]
36 else:
37 return self._stringtoformat
38
39@@ -817,7 +818,7 @@
40 header_next = False
41 for row, line in enumerate(text.splitlines()[:max_format_lines]):
42 result.append('<tr>')
43- result.append('<td class="line-no">%s</td>' % (row+1))
44+ result.append('<td class="line-no">%s</td>' % (row + 1))
45 if line.startswith('==='):
46 css_class = 'diff-file text'
47 header_next = True
48@@ -850,13 +851,26 @@
49 result.append('</table>')
50 return ''.join(result)
51
52- _css_id_strip_pattern = re.compile(r'[^a-zA-Z0-9-]+')
53+ _css_id_strip_pattern = re.compile(r'[^a-zA-Z0-9-_]+')
54+ _zope_css_id_strip_pattern = re.compile(r'[^a-zA-Z0-9-_\.]+')
55
56 def css_id(self, prefix=None):
57+ """Return a CSS compliant id."""
58+ return self._css_id(self._css_id_strip_pattern, prefix)
59+
60+ def zope_css_id(self, prefix=None):
61+ """Return a CSS compliant id compatible with zope's form fields.
62+
63+ The strip pattern allows ids which contain periods which is required
64+ for compatibility with zope form fields.
65+ """
66+ return self._css_id(self._zope_css_id_strip_pattern, prefix)
67+
68+ def _css_id(self, strip_pattern, prefix=None):
69 """Return a CSS compliant id.
70
71- The id may contain letters, numbers, and hyphens. The first
72- character must be a letter. Unsupported characters are converted
73+ The id may contain letters, numbers, hyphens and underscores. The
74+ first character must be a letter. Unsupported characters are converted
75 to hyphens. Multiple characters are replaced by a single hyphen. The
76 letter 'j' will start the id if the string's first character is not a
77 letter.
78@@ -868,8 +882,25 @@
79 raw_text = prefix + self._stringtoformat
80 else:
81 raw_text = self._stringtoformat
82- id_ = self._css_id_strip_pattern.sub('-', raw_text)
83- if id_[0] in '-0123456789':
84+ id_ = strip_pattern.sub('-', raw_text)
85+
86+ # If any characters are converted to a hyphen, we cannot be 100%
87+ # assured of a unique id unless we take further action. Note that we
88+ # use the _zope_css_id_strip_pattern for the check because ids with
89+ # periods are still in common usage.
90+ if self._zope_css_id_strip_pattern.search(raw_text):
91+ # We need to ensure that the id is always guaranteed to be unique,
92+ # hence we append URL-safe base 64 encoding of the name. However
93+ # we also have to strip off any padding characters ("=") because
94+ # Python's URL-safe base 64 encoding includes those and they
95+ # aren't allowed in IDs either.
96+ unique_suffix = urlsafe_b64encode(raw_text)
97+ # Ensure we put a '-' between the id and base 64 encoding.
98+ if id_[-1] != '-':
99+ id_ += '-'
100+ id_ += unique_suffix.replace('=', '')
101+
102+ if id_[0] in '-_0123456789':
103 # 'j' is least common starting character in technical usage;
104 # engineers love 'z', 'q', and 'y'.
105 return 'j' + id_
106@@ -926,6 +957,11 @@
107 return self.css_id(furtherPath.pop())
108 else:
109 return self.css_id()
110+ elif name == 'zope-css-id':
111+ if len(furtherPath) > 0:
112+ return self.zope_css_id(furtherPath.pop())
113+ else:
114+ return self.zope_css_id()
115 elif name == 'oops-id':
116 return self.oops_id()
117 else:
118
119=== modified file 'lib/lp/app/doc/tales.txt'
120--- lib/lp/app/doc/tales.txt 2011-03-17 03:03:33 +0000
121+++ lib/lp/app/doc/tales.txt 2011-05-29 23:36:46 +0000
122@@ -1033,29 +1033,39 @@
123 -------
124
125 Strings can be converted to valid CSS ids. The id will start with 'j' if
126-the start of the string is not a letter.
127+the start of the string is not a letter. If any invalid characters are
128+stripped out, to ensure the id is unique, a base64 encoding is appended to the
129+id.
130
131 >>> test_tales('foo/fmt:css-id', foo='beta2-milestone')
132 'beta2-milestone'
133
134 >>> test_tales('foo/fmt:css-id', foo='user name')
135- 'user-name'
136+ 'user-name-dXNlciBuYW1l'
137
138 >>> test_tales('foo/fmt:css-id', foo='1.0.1_series')
139- 'j1-0-1-series'
140+ 'j1-0-1_series'
141
142 An optional prefix for the if can be added to the path. It too will be
143 escaped.
144
145 >>> test_tales('foo/fmt:css-id/series-', foo='1.0.1_series')
146- 'series-1-0-1-series'
147+ 'series-1-0-1_series'
148
149 >>> test_tales('foo/fmt:css-id/series_', foo='1.0.1_series')
150- 'series-1-0-1-series'
151+ 'series_1-0-1_series'
152
153 >>> test_tales('foo/fmt:css-id/0series-', foo='1.0.1_series')
154- 'j0series-1-0-1-series'
155-
156+ 'j0series-1-0-1_series'
157+
158+Zope fields are rendered with a period, and we need to ensure there is a way
159+to retain the periods in the css id even though we would prefer not to.
160+
161+ >>> test_tales('foo/fmt:zope-css-id', foo='field.bug.target')
162+ 'field.bug.target'
163+
164+ >>> test_tales('foo/fmt:zope-css-id', foo='field.gtk+_package')
165+ 'field.gtk-_package-ZmllbGQuZ3RrK19wYWNrYWdl'
166
167 The fmt: namespace to get strings (obfuscation)
168 -----------------------------------------------
169
170=== modified file 'lib/lp/app/widgets/popup.py'
171--- lib/lp/app/widgets/popup.py 2011-05-05 15:58:47 +0000
172+++ lib/lp/app/widgets/popup.py 2011-05-29 23:36:46 +0000
173@@ -7,9 +7,9 @@
174
175 __metaclass__ = type
176
177-from base64 import urlsafe_b64encode
178 import cgi
179
180+import re
181 import simplejson
182 from z3c.ptcompat import ViewPageTemplateFile
183 from zope.app.form.browser.itemswidgets import (
184@@ -19,6 +19,7 @@
185 from zope.schema.interfaces import IChoice
186
187 from canonical.launchpad.webapp import canonical_url
188+from lp.app.browser.stringformatter import FormattersAPI
189 from lp.services.propertycache import cachedproperty
190
191
192@@ -80,7 +81,7 @@
193 def inputField(self):
194 d = {
195 'formToken': cgi.escape(self.formToken, quote=True),
196- 'name': self.name,
197+ 'name': self.input_id,
198 'displayWidth': self.displayWidth,
199 'displayMaxWidth': self.displayMaxWidth,
200 'onKeyPress': self.onKeyPress,
201@@ -94,19 +95,8 @@
202 class="%(cssClass)s" />""" % d
203
204 @property
205- def suffix(self):
206- """This is used to uniquify the widget ID to avoid ID collisions."""
207- # Since this will be used in an HTML ID, the allowable set of
208- # characters is smaller than the set that can appear in self.name.
209- # Therefore we use the URL-safe base 64 encoding of the name. However
210- # we also have to strip off any padding characters ("=") because
211- # Python's URL-safe base 64 encoding includes those and they aren't
212- # allowed in IDs either.
213- return urlsafe_b64encode(self.name).replace('=', '')
214-
215- @property
216 def show_widget_id(self):
217- return 'show-widget-%s' % self.suffix
218+ return 'show-widget-%s' % self.input_id.replace('.', '-')
219
220 @property
221 def extra_no_results_message(self):
222@@ -144,7 +134,8 @@
223
224 @property
225 def input_id(self):
226- return self.name
227+ """This is used to ensure the widget id contains only valid chars."""
228+ return FormattersAPI(self.name).zope_css_id()
229
230 def chooseLink(self):
231 if self.nonajax_uri is None:
232
233=== modified file 'lib/lp/app/widgets/tests/test_popup.py'
234--- lib/lp/app/widgets/tests/test_popup.py 2011-05-05 15:58:47 +0000
235+++ lib/lp/app/widgets/tests/test_popup.py 2011-05-29 23:36:46 +0000
236@@ -4,31 +4,58 @@
237 __metaclass__ = type
238
239 import simplejson
240+
241+from zope.interface import Interface
242+from zope.interface.interface import InterfaceClass
243+from zope.schema import Choice
244 from zope.schema.vocabulary import getVocabularyRegistry
245
246 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
247 from canonical.testing.layers import DatabaseFunctionalLayer
248 from lp.app.widgets.popup import VocabularyPickerWidget
249-from lp.registry.interfaces.person import ITeam
250 from lp.testing import TestCaseWithFactory
251
252
253+class TestMetaClass(InterfaceClass):
254+# We want to force creation of a field with an invalid HTML id.
255+ def __init__(self, name, bases=(), attrs=None, __doc__=None,
256+ __module__=None):
257+ attrs = {
258+ "test_invalid_chars+":
259+ Choice(vocabulary='ValidTeamOwner'),
260+ "test_valid.item":
261+ Choice(vocabulary='ValidTeamOwner')}
262+ super(TestMetaClass, self).__init__(
263+ name, bases=bases, attrs=attrs, __doc__=__doc__,
264+ __module__=__module__)
265+
266+
267+class ITest(Interface):
268+# The schema class for the widget we will test.
269+ __metaclass__ = TestMetaClass
270+
271+
272 class TestVocabularyPickerWidget(TestCaseWithFactory):
273
274 layer = DatabaseFunctionalLayer
275
276 def setUp(self):
277 super(TestVocabularyPickerWidget, self).setUp()
278- context = self.factory.makeTeam()
279- field = ITeam['teamowner']
280- self.bound_field = field.bind(context)
281+ self.context = self.factory.makeTeam()
282 vocabulary_registry = getVocabularyRegistry()
283- self.vocabulary = vocabulary_registry.get(context, 'ValidTeamOwner')
284+ self.vocabulary = vocabulary_registry.get(
285+ self.context, 'ValidTeamOwner')
286 self.request = LaunchpadTestRequest()
287
288 def test_widget_template_properties(self):
289+ # Check the picker widget is correctly set up for a field which has a
290+ # name containing only valid HTML ID characters.
291+
292+ field = ITest['test_valid.item']
293+ bound_field = field.bind(self.context)
294 picker_widget = VocabularyPickerWidget(
295- self.bound_field, self.vocabulary, self.request)
296+ bound_field, self.vocabulary, self.request)
297+
298 self.assertEqual(
299 'ValidTeamOwner', picker_widget.vocabulary_name)
300 self.assertEqual(
301@@ -37,12 +64,27 @@
302 self.assertEqual(
303 simplejson.dumps(self.vocabulary.step_title),
304 picker_widget.step_title_text)
305- # The widget name is encoded to get the widget's ID. The content of
306- # the ID is unimportant, the fact that it is unique on the page and a
307- # valid HTML element ID are what's important.
308- self.assertEqual(
309- 'show-widget-ZmllbGQudGVhbW93bmVy', picker_widget.show_widget_id)
310- self.assertEqual(
311- 'field.teamowner', picker_widget.input_id)
312+ self.assertEqual(
313+ 'show-widget-field-test_valid-item', picker_widget.show_widget_id)
314+ self.assertEqual(
315+ 'field.test_valid.item', picker_widget.input_id)
316 self.assertEqual(
317 simplejson.dumps(None), picker_widget.extra_no_results_message)
318+
319+ def test_widget_fieldname_with_invalid_html_chars(self):
320+ # Check the picker widget is correctly set up for a field which has a
321+ # name containing some invalid HTML ID characters.
322+
323+ field = ITest['test_invalid_chars+']
324+ bound_field = field.bind(self.context)
325+ picker_widget = VocabularyPickerWidget(
326+ bound_field, self.vocabulary, self.request)
327+
328+ # The widget name is encoded to get the widget's ID. It must only
329+ # contain valid HTML characters.
330+ self.assertEqual(
331+ 'show-widget-field-test_invalid_chars-ZmllbGQudGVzdF9pbnZhbGlkX2NoYXJzKw',
332+ picker_widget.show_widget_id)
333+ self.assertEqual(
334+ 'field.test_invalid_chars-ZmllbGQudGVzdF9pbnZhbGlkX2NoYXJzKw',
335+ picker_widget.input_id)