~launchpad-pqm/launchpad/devel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
Hierarchical menus
==================

The location bar aids users in navigating the depths of Launchpad.  It
is built from a list of Breadcrumb objects collected during Zope's
object-traversal step.

A simple object hierarchy
-------------------------

First, we need a hierarchy of objects to build upon:

    >>> from zope.component import getMultiAdapter, provideAdapter
    >>> from zope.interface import Interface, implements

    >>> class ICookbook(Interface):
    ...     """A cookbook for holding recipes."""

    >>> class IRecipe(Interface):
    ...     """A recipe in a cookbook."""

    >>> class ICooker(Interface):
    ...     """A cooker."""

    >>> from lp.services.webapp.interfaces import ICanonicalUrlData
    >>> from lp.services.webapp.url import urlappend

    >>> class BaseContent:
    ...     implements(ICanonicalUrlData)
    ...
    ...     def __init__(self, name, parent, path_prefix=None):
    ...         self.name = name
    ...         if path_prefix is not None:
    ...             self.path = urlappend(path_prefix, name)
    ...         else:
    ...             self.path = name
    ...         self.inside = parent
    ...         self.rootsite = None

    >>> class Root(BaseContent):
    ...     """ The site root."""

    >>> class Cookbook(BaseContent):
    ...     implements(ICookbook)

    >>> class Recipe(BaseContent):
    ...     implements(IRecipe)

    >>> class Cooker(BaseContent):
    ...     implements(ICooker)

Today we'll be cooking with Spam!

    >>> root = Root('', None)
    >>> cooker = Cooker('jamie', root, '+cooker')
    >>> cookbook = Cookbook('joy-of-cooking', root)
    >>> recipe = Recipe('spam', cookbook)


Discovering breadcrumbs
-----------------------

The Hierarchy class builds the breadcrumbs by looking at each object in
the request.traversed_objects attribute.  If a traversed object can be
adapted to IBreadcrumb, then it is added to the breadcrumbs list.

We'll add the objects to the request's list of traversed objects so
the hierarchy will discover them.

    >>> from lp.testing.menu import make_fake_request
    >>> request = make_fake_request(
    ...     'http://launchpad.dev/joy-of-cooking/spam',
    ...     [root, cookbook, recipe])

The hierarchy's list of breadcrumbs is empty since none of the objects
have an IBreadcrumb adapter.

    >>> hierarchy = getMultiAdapter((recipe, request), name='+hierarchy')
    >>> hierarchy.items
    []

The ICookbook and IRecipe breadcrumb objects show up in the hierarchy after
IBreadcrumb adapters are registered for them.  The hierarchy builds a list of
breadcrumbs starting with the breadcrumb closest to the hierarchy root.

    >>> from lp.app.browser.launchpad import Hierarchy
    >>> from lp.services.webapp.breadcrumb import Breadcrumb

    # Monkey patch Hierarchy.makeBreadcrumbForRequestedPage so that we don't
    # have to create fake views and other stuff to test breadcrumbs here. The
    # functionality provided by that method is tested in
    # webapp/tests/test_breadcrumbs.py.
    >>> make_breadcrumb_func = Hierarchy.makeBreadcrumbForRequestedPage
    >>> Hierarchy.makeBreadcrumbForRequestedPage = lambda self: None

    # Note that the Hierarchy assigns the breadcrumb's URL, but we need to
    # give it a valid .text attribute.
    >>> class TextualBreadcrumb(Breadcrumb):
    ...     @property
    ...     def text(self):
    ...         return self.context.name.capitalize().replace('-', ' ')

    >>> from lp.services.webapp.interfaces import IBreadcrumb

    >>> provideAdapter(TextualBreadcrumb, [ICookbook], IBreadcrumb)
    >>> provideAdapter(TextualBreadcrumb, [IRecipe], IBreadcrumb)

    >>> hierarchy = getMultiAdapter((recipe, request), name='+hierarchy')
    >>> hierarchy.items
    [<TextualBreadcrumb
        url='http://launchpad.dev/joy-of-cooking'
        text='Joy of cooking'>,
     <TextualBreadcrumb
        url='http://launchpad.dev/joy-of-cooking/spam'
        text='Spam'>]

The ICooker object contains a path prefix, a segment of the path that
does not correspond to any object, it's only used to split traversal
domains. The `Hierarchy` model copes fine with objects like that.

    >>> cooker_request = make_fake_request(
    ...     'http://launchpad.dev/+cooker/jamie',
    ...     [root, cooker])

    >>> provideAdapter(TextualBreadcrumb, [ICooker], IBreadcrumb)

    >>> cooker_hierarchy = getMultiAdapter(
    ...     (cooker, cooker_request), name='+hierarchy')
    >>> cooker_hierarchy.items
    [<TextualBreadcrumb url='.../+cooker/jamie' text='Jamie'>]


Displaying breadcrumbs
----------------------

Breadcrumbs are only displayed if there is more than one breadcrumb, as
otherwise the breadcrumb will simply replicate the context.title heading
above it.

    >>> len(hierarchy.items)
    2
    >>> hierarchy.display_breadcrumbs
    True

    >>> len(cooker_hierarchy.items)
    1
    >>> cooker_hierarchy.display_breadcrumbs
    False

Additionally, if the view implements IMajorHeadingView then the breadcrumbs
will not be displayed.

    >>> ham_recipe = Recipe('ham', cookbook)
    >>> ham_request = make_fake_request(
    ...     'http://launchpad.dev/joy-of-cooking/ham',
    ...     [root, cookbook, ham_recipe])

    >>> ham_hierarchy = getMultiAdapter(
    ...     (ham_recipe, ham_request), name='+hierarchy')
    >>> hierarchy.display_breadcrumbs
    True

    >>> from zope.interface import alsoProvides
    >>> from lp.app.interfaces.headings import IMajorHeadingView
    >>> alsoProvides(ham_recipe, IMajorHeadingView)
    >>> ham_hierarchy.display_breadcrumbs
    False


Building IBreadcrumb objects
----------------------------

The construction of breadcrumb objects is handled by an IBreadcrumb adapter,
which adapts a context object and produces an IBreadcrumb object for that
context.  The default adapter provides the url attribute, but the breadcrumb's
text must be overriden in subclasses.

    >>> from zope.interface.verify import verifyObject
    >>> from lp.services.webapp.interfaces import IBreadcrumb
    >>> breadcrumb = Breadcrumb(cookbook)
    >>> verifyObject(IBreadcrumb, breadcrumb)
    True
    >>> print breadcrumb.text
    None
    >>> print breadcrumb.url
    http://launchpad.dev/joy-of-cooking

As said above, the breadcrumb's attributes can be overridden with subclassing
and Python properties.

    >>> class DynamicBreadcrumb(Breadcrumb):
    ...     @property
    ...     def text(self):
    ...         return self.context.name.capitalize().replace('-', ' ')

    >>> breadcrumb = DynamicBreadcrumb(cookbook)
    >>> breadcrumb
    <DynamicBreadcrumb
        url='http://launchpad.dev/joy-of-cooking'
        text='Joy of cooking'>


Customizing the hierarchy
-------------------------

We can customize the hierarchy itself by changing the list of objects
and URLs that it uses to construct the breadcrumbs list.

The Hierarchy object should *not* construct the Breadcrumb objects
itself.  It should let the IBreadcrumbBuilder handle it: this ensures
consistency across the site.

    >>> class CustomHierarchy(Hierarchy):
    ...     @property
    ...     def objects(self):
    ...         return [recipe]

    >>> spammy_hierarchy = CustomHierarchy(root, request)
    >>> spammy_hierarchy.items
    [<TextualBreadcrumb
        url='http://launchpad.dev/joy-of-cooking/spam'
        text='Spam'>]


Rendering the list
------------------

The Hierarchy object is responsible for rendering the HTML for the
location bar.

    >>> from BeautifulSoup import BeautifulSoup
    >>> from lp.testing.pages import extract_text

    # Borrowed from lp.testing.pages.print_location()
    >>> def print_hierarchy(html):
    ...     soup = BeautifulSoup(html)
    ...     hierarchy = soup.find(attrs={'class': 'breadcrumbs'}).findAll(
    ...         recursive=False)
    ...     segments = [extract_text(step).encode('us-ascii', 'replace')
    ...                 for step in hierarchy]
    ...     print 'Location:', ' > '.join(segments)

    >>> markup = hierarchy.render()
    >>> print_hierarchy(markup)
    Location: Joy of cooking > Spam

The items in the breadcrumbs are linked, except for the last one which
represents the current location.

    >>> print markup
    <ol itemprop="breadcrumb" class="breadcrumbs">
      <li>
        <a href="http://launchpad.dev/joy-of-cooking">Joy of cooking</a>
      </li>
      <li>
          Spam
      </li>
    </ol>

The Launchpad Homepage displays no items in its location bar.  We are
considered to be on the home page if there are no breadcrumbs.

    # Simulate a visit to the site root
    >>> request = make_fake_request('http://launchpad.dev/', [root])
    >>> homepage_hierarchy = getMultiAdapter(
    ...     (root, request), name='+hierarchy')

    >>> homepage_hierarchy.items
    []

    >>> homepage_hierarchy.render().strip()
    u''


Put the monkey patched method back.

    >>> Hierarchy.makeBreadcrumbForRequestedPage = make_breadcrumb_func