Merge lp:~methanal-developers/methanal/extend-tab-api into lp:methanal
- extend-tab-api
- Merge into trunk
Proposed by
Jonathan Jacobs
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Tristan Seligmann | ||||
Approved revision: | 140 | ||||
Merged at revision: | 138 | ||||
Proposed branch: | lp:~methanal-developers/methanal/extend-tab-api | ||||
Merge into: | lp:methanal | ||||
Diff against target: |
1076 lines (+566/-166) 6 files modified
methanal/errors.py (+7/-0) methanal/js/Methanal/Tests/TestWidgets.js (+17/-3) methanal/js/Methanal/Widgets.js (+194/-69) methanal/test/test_widgets.py (+138/-44) methanal/themes/methanal-base/methanal-tab-view.html (+1/-1) methanal/widgets.py (+209/-49) |
||||
To merge this branch: | bzr merge lp:~methanal-developers/methanal/extend-tab-api | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Tristan Seligmann | Approve | ||
Review via email: mp+24080@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
Tristan Seligmann (mithrandi) : | # |
review:
Approve
- 141. By Jonathan Jacobs
-
Rename private TabView method to be more accurate.
- 142. By Jonathan Jacobs
-
Fix test breakage.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'methanal/errors.py' |
2 | --- methanal/errors.py 2009-09-17 16:36:09 +0000 |
3 | +++ methanal/errors.py 2010-05-23 13:10:50 +0000 |
4 | @@ -9,3 +9,10 @@ |
5 | """ |
6 | An invalid enumeration value was specified. |
7 | """ |
8 | + |
9 | + |
10 | + |
11 | +class InvalidIdentifier(ValueError): |
12 | + """ |
13 | + An invalid identifier was specified. |
14 | + """ |
15 | |
16 | === modified file 'methanal/js/Methanal/Tests/TestWidgets.js' |
17 | --- methanal/js/Methanal/Tests/TestWidgets.js 2010-03-15 18:58:03 +0000 |
18 | +++ methanal/js/Methanal/Tests/TestWidgets.js 2010-05-23 13:10:50 +0000 |
19 | @@ -430,6 +430,7 @@ |
20 | node, _tabIDs, _tabGroups, topLevel || false, _makeThrobber); |
21 | Methanal.Tests.Util.makeWidgetChildNode(tabView, 'div', 'throbber'); |
22 | Methanal.Tests.Util.makeWidgetChildNode(tabView, 'ul', 'labels'); |
23 | + Methanal.Tests.Util.makeWidgetChildNode(tabView, 'div', 'contents'); |
24 | document.body.appendChild(node); |
25 | Methanal.Util.nodeInserted(tabView); |
26 | return tabView; |
27 | @@ -449,6 +450,7 @@ |
28 | 'group': group || null}) |
29 | tabView.addChildWidget(tab); |
30 | Methanal.Tests.Util.makeWidgetChildNode(tab, 'content'); |
31 | + tabView.nodeById('contents').appendChild(tab.node); |
32 | Methanal.Util.nodeInserted(tab); |
33 | return tab; |
34 | }, |
35 | @@ -585,7 +587,7 @@ |
36 | */ |
37 | function test_groups(self) { |
38 | function checkGroup(group) { |
39 | - var groupNode = tabView._groups[group.id]; |
40 | + var groupNode = tabView._groups[group.id].content; |
41 | self.assertNotIdentical(groupNode, undefined); |
42 | self.assertIdentical( |
43 | groupNode.getElementsByTagName('li').length, |
44 | @@ -614,7 +616,8 @@ |
45 | |
46 | /** |
47 | * Group visibility is determined by the visibility of the tabs it |
48 | - * contains. |
49 | + * contains. Removing all the tabs from a group will result in that group |
50 | + * becoming invisible. |
51 | */ |
52 | function test_groupVisibility(self) { |
53 | var group1 = Methanal.Widgets.TabGroup( |
54 | @@ -627,7 +630,7 @@ |
55 | var tab3 = self.createTab( |
56 | tabView, 'tab3', 'Tab 3', undefined, group1.id); |
57 | |
58 | - var node = tabView._groups[group1.id].parentNode; |
59 | + var node = tabView._groups[group1.id].content.parentNode; |
60 | tabView.hideTab(tab1); |
61 | self.assertNodeVisible(node); |
62 | tabView.hideTab(tab2); |
63 | @@ -636,4 +639,15 @@ |
64 | self.assertNodeHidden(node); |
65 | tabView.showTab(tab3); |
66 | self.assertNodeVisible(node); |
67 | + |
68 | + // Stub out Nevow.Athena.Widget.detach. |
69 | + function detach() {} |
70 | + |
71 | + tab2.detach = detach; |
72 | + tabView.removeTab(tab2); |
73 | + self.assertNodeVisible(node); |
74 | + |
75 | + tab3.detach = detach; |
76 | + tabView.removeTab(tab3); |
77 | + self.assertNodeHidden(node); |
78 | }); |
79 | |
80 | === modified file 'methanal/js/Methanal/Widgets.js' |
81 | --- methanal/js/Methanal/Widgets.js 2010-03-15 18:58:15 +0000 |
82 | +++ methanal/js/Methanal/Widgets.js 2010-05-23 13:10:50 +0000 |
83 | @@ -1440,25 +1440,158 @@ |
84 | |
85 | |
86 | /** |
87 | - * Client-side handler for appending a tabs from the server-side. |
88 | - */ |
89 | - function _appendTabsFromServer(self, widgetInfos, tabGroups) { |
90 | + * Select the first visible tab. |
91 | + */ |
92 | + function selectFirstVisibleTab(self) { |
93 | + try { |
94 | + self.selectTab(self.getFirstVisibleTab()); |
95 | + } catch (e) { |
96 | + if (!(e instanceof Methanal.Widgets.UnknownTab)) { |
97 | + throw e; |
98 | + } |
99 | + } |
100 | + }, |
101 | + |
102 | + |
103 | + /** |
104 | + * Client-side handler for updating tabs from the server-side. |
105 | + * |
106 | + * @param widgetInfos: New tab widgets to add. |
107 | + * |
108 | + * @type tabIDsToRemove: C{Array} of C{String} |
109 | + * @param tabIDsToRemove: Identifiers of L{methanal.widgets.Tab}s to remove. |
110 | + * |
111 | + * @param tabGroups: Tab groups. |
112 | + */ |
113 | + function _updateTabsFromServer(self, widgetInfos, tabIDsToRemove, tabGroups) { |
114 | self.throbber.start(); |
115 | self.tabGroups = tabGroups; |
116 | |
117 | - function _appendTab(widgetInfo) { |
118 | + function _updateTab(widgetInfo) { |
119 | var d = self.addChildWidgetFromWidgetInfo(widgetInfo); |
120 | - d.addCallback(function (widget) { |
121 | - self.tabIDs[widget.id] = true; |
122 | - self.node.appendChild(widget.node); |
123 | - Methanal.Util.nodeInserted(widget); |
124 | + d.addCallback(function (tab) { |
125 | + var oldTab = self._tabs[tab.id]; |
126 | + // If we are updating an existing tab then detach and remove |
127 | + // the old one first. Selection state is transferred too. |
128 | + if (oldTab !== undefined) { |
129 | + tab.selected = oldTab.selected; |
130 | + self._removeTabContent(oldTab); |
131 | + } |
132 | + self.tabIDs[tab.id] = true; |
133 | + self.nodeById('contents').appendChild(tab.node); |
134 | + Methanal.Util.nodeInserted(tab); |
135 | return null; |
136 | }); |
137 | return d; |
138 | } |
139 | |
140 | - var ds = Methanal.Util.map(_appendTab, widgetInfos); |
141 | - return Divmod.Defer.gatherResults(ds); |
142 | + var d = Divmod.Defer.gatherResults( |
143 | + Methanal.Util.map(_updateTab, widgetInfos)); |
144 | + if (tabIDsToRemove.length > 0) { |
145 | + d.addCallback(function () { |
146 | + return self._removeTabsFromServer(tabIDsToRemove, tabGroups); |
147 | + }); |
148 | + } |
149 | + return d; |
150 | + }, |
151 | + |
152 | + |
153 | + /** |
154 | + * Remove a tab. |
155 | + * |
156 | + * Group visibility is updated too, meaning any groups that contain no |
157 | + * visible tabs will be hidden. |
158 | + */ |
159 | + function removeTab(self, tab) { |
160 | + var d = self._removeTabContent(tab); |
161 | + var label = self._labels[tab.id]; |
162 | + label.parentNode.removeChild(label); |
163 | + delete self._labels[tab.id]; |
164 | + delete self._tabs[tab.id]; |
165 | + if (tab.group !== null) { |
166 | + self._updateGroupVisiblity(tab.group); |
167 | + } |
168 | + return d; |
169 | + }, |
170 | + |
171 | + |
172 | + /** |
173 | + * Remove a tab widget's content. |
174 | + */ |
175 | + function _removeTabContent(self, tab) { |
176 | + self.nodeById('contents').removeChild(tab.node); |
177 | + return tab.detach(); |
178 | + }, |
179 | + |
180 | + |
181 | + /** |
182 | + * Client-side handler for removing tabs from the server-side. |
183 | + * |
184 | + * @type tabIDs: C{Array} of C{String} |
185 | + * @param tabIDs: Identifiers of L{methanal.widgets.Tab}s to remove. |
186 | + * |
187 | + * @param tabGroups: Tab groups. |
188 | + */ |
189 | + function _removeTabsFromServer(self, tabIDs, tabGroups) { |
190 | + self.tabGroups = tabGroups; |
191 | + var wasSelected = false; |
192 | + |
193 | + function _removeTab(tabID) { |
194 | + var tab = self.getTab(tabID); |
195 | + wasSelected |= tab.selected; |
196 | + return self.removeTab(tab); |
197 | + } |
198 | + |
199 | + var d = Divmod.Defer.gatherResults( |
200 | + Methanal.Util.map(_removeTab, tabIDs)); |
201 | + |
202 | + d.addCallback(function () { |
203 | + // If one of the removed tabs was selected then select another one. |
204 | + if (wasSelected) { |
205 | + self.selectFirstVisibleTab(); |
206 | + } |
207 | + return null; |
208 | + }); |
209 | + return d; |
210 | + }, |
211 | + |
212 | + |
213 | + /** |
214 | + * Create or update a tab label. |
215 | + * |
216 | + * Tab group titles will be updated too. |
217 | + */ |
218 | + function _createLabel(self, tab) { |
219 | + var D = Methanal.Util.DOMBuilder(self.node.ownerDocument); |
220 | + var label = D('li', {'class': 'methanal-tab-label'}, [ |
221 | + D('a', {}, [tab.title])]); |
222 | + label.onclick = function onclick(node) { |
223 | + self.selectTab(tab); |
224 | + }; |
225 | + |
226 | + var labelClassName = tab.getLabelClassName(); |
227 | + if (labelClassName) { |
228 | + Methanal.Util.addElementClass(label, labelClassName); |
229 | + } |
230 | + |
231 | + var labelParent = self.nodeById('labels'); |
232 | + if (tab.group !== null) { |
233 | + var nodes = self._getGroupNodes(tab.group); |
234 | + Methanal.Util.replaceNodeText( |
235 | + nodes.label, self.getGroup(tab.group).title); |
236 | + self._updateGroupVisiblity(tab.group); |
237 | + labelParent = nodes.content; |
238 | + } |
239 | + |
240 | + var oldLabel = self._labels[tab.id]; |
241 | + // If a label for this tab already exists, then update that label. |
242 | + if (oldLabel !== undefined) { |
243 | + oldLabel.parentNode.replaceChild(label, oldLabel); |
244 | + } else { |
245 | + labelParent.appendChild(label); |
246 | + } |
247 | + |
248 | + self._labels[tab.id] = label; |
249 | }, |
250 | |
251 | |
252 | @@ -1470,22 +1603,24 @@ |
253 | */ |
254 | function _createGroupNode(self, id) { |
255 | var title = self.getGroup(id).title; |
256 | - |
257 | var D = Methanal.Util.DOMBuilder(self.node.ownerDocument); |
258 | - var group = D('ul', {'class': 'methanal-tab-group-tabs'}, []); |
259 | - self._groups[id] = group; |
260 | - var groupInner = D( |
261 | + var content = D('ul', {'class': 'methanal-tab-group-tabs'}, []); |
262 | + var label = D('div', {'class': 'methanal-tab-group-label'}, [title]); |
263 | + var inner = D( |
264 | 'li', {'class': 'methanal-tab-label methanal-tab-group'}, [ |
265 | - D('div', {'class': 'methanal-tab-group-label'}, [title]), |
266 | - group]); |
267 | - self.nodeById('labels').appendChild(groupInner); |
268 | + label, content]); |
269 | + self._groups[id] = { |
270 | + 'content': content, |
271 | + 'label': label}; |
272 | + self.nodeById('labels').appendChild(inner); |
273 | }, |
274 | |
275 | |
276 | /** |
277 | - * Get the DOM node for a group, creating it if it doesn't already exist. |
278 | + * Get the DOM nodes for a group's container and label, creating them if |
279 | + * they don't exist. |
280 | */ |
281 | - function _getGroupNode(self, id) { |
282 | + function _getGroupNodes(self, id) { |
283 | if (!(id in self._groups)) { |
284 | self._createGroupNode(id); |
285 | } |
286 | @@ -1499,35 +1634,14 @@ |
287 | * A new label is created for C{tab}, based on L{Tab.title}, and an |
288 | * C{onclick} handler attached. |
289 | */ |
290 | - function appendTab(self, tab) { |
291 | - function onclick(node) { |
292 | - self.selectTab(tab); |
293 | - } |
294 | - |
295 | - var D = Methanal.Util.DOMBuilder(self.node.ownerDocument); |
296 | - var label = D('li', {'class': 'methanal-tab-label'}, [ |
297 | - D('a', {}, [tab.title])]); |
298 | - label.onclick = onclick; |
299 | - |
300 | - var labelClassName = tab.getLabelClassName(); |
301 | - if (labelClassName) { |
302 | - Methanal.Util.addElementClass(label, labelClassName); |
303 | - } |
304 | - |
305 | - self._labels[tab.id] = label; |
306 | + function updateTab(self, tab) { |
307 | self._tabs[tab.id] = tab; |
308 | - |
309 | - var labelParent = self.nodeById('labels'); |
310 | - if (tab.group !== null) { |
311 | - labelParent = self._getGroupNode(tab.group); |
312 | - self._updateGroupVisiblity(tab.group); |
313 | - } |
314 | - labelParent.appendChild(label); |
315 | + self._createLabel(tab); |
316 | |
317 | // If this tab's identifier matches the one we're supposed to select, |
318 | // do it. Otherwise select this tab if its "selected" attribute is true |
319 | // and no tab selection has been made yet. |
320 | - if (tab.id == self._idToSelect) { |
321 | + if (tab.id === self._idToSelect) { |
322 | self._tabToSelect = tab; |
323 | } else if (self._tabToSelect === undefined && tab.selected) { |
324 | self._tabToSelect = tab; |
325 | @@ -1535,6 +1649,12 @@ |
326 | }, |
327 | |
328 | |
329 | + function appendTab(self, tab) { |
330 | + Divmod.msg('DEPRECATED: Use Methanal.Widgets.TabView.updateTab'); |
331 | + return self.updateTab(tab); |
332 | + }, |
333 | + |
334 | + |
335 | /** |
336 | * Select a tab. |
337 | * |
338 | @@ -1559,6 +1679,9 @@ |
339 | if (!self.fullyLoaded) { |
340 | self._tabToSelect = tab; |
341 | self._idToSelect = tab.id; |
342 | + } else { |
343 | + self._tabToSelect = undefined; |
344 | + self._idToSelect = undefined; |
345 | } |
346 | }, |
347 | |
348 | @@ -1594,7 +1717,7 @@ |
349 | |
350 | // Change the visibility of the group parent node, not the inner |
351 | // container. |
352 | - var groupNode = self._groups[groupID].parentNode; |
353 | + var groupNode = self._getGroupNodes(groupID).content.parentNode; |
354 | if (visible) { |
355 | Methanal.Util.removeElementClass(groupNode, 'hidden'); |
356 | } else { |
357 | @@ -1638,13 +1761,7 @@ |
358 | tab.visible = false; |
359 | // If we're hiding the selected tab, select the first visible tab. |
360 | if (tab.selected) { |
361 | - try { |
362 | - self.selectTab(self.getFirstVisibleTab()); |
363 | - } catch (e) { |
364 | - if (!(e instanceof Methanal.Widgets.UnknownTab)) { |
365 | - throw e; |
366 | - } |
367 | - } |
368 | + self.selectFirstVisibleTab(); |
369 | } |
370 | |
371 | self._updateGroupVisiblity(tab.group); |
372 | @@ -1658,7 +1775,7 @@ |
373 | * finalised by calling L{_finishLoading}. |
374 | */ |
375 | function loadedUp(self, tab) { |
376 | - self.appendTab(tab); |
377 | + self.updateTab(tab); |
378 | |
379 | delete self.tabIDs[tab.id]; |
380 | for (var id in self.tabIDs) { |
381 | @@ -1674,13 +1791,11 @@ |
382 | */ |
383 | function _finishLoading(self) { |
384 | self.throbber.stop(); |
385 | - if (self.fullyLoaded) { |
386 | - return; |
387 | - } |
388 | - |
389 | - self.fullyLoaded = true; |
390 | - if (self._tabToSelect === undefined && self.childWidgets.length > 0) { |
391 | - self._tabToSelect = self.childWidgets[0]; |
392 | + if (!self.fullyLoaded) { |
393 | + self.fullyLoaded = true; |
394 | + if (self._tabToSelect === undefined && self.childWidgets.length > 0) { |
395 | + self._tabToSelect = self.childWidgets[0]; |
396 | + } |
397 | } |
398 | if (self._tabToSelect) { |
399 | self.selectTab(self._tabToSelect); |
400 | @@ -1780,6 +1895,25 @@ |
401 | |
402 | |
403 | /** |
404 | + * Set an Athena widget as the tab content. |
405 | + */ |
406 | + function _setContentFromWidgetInfo(self, widgetInfo, abortFetch) { |
407 | + var d = self.addChildWidgetFromWidgetInfo(widgetInfo); |
408 | + d.addCallback(function (widget) { |
409 | + if (abortFetch) { |
410 | + return widget.detach(); |
411 | + } else { |
412 | + self._setContent(widget.node); |
413 | + self._currentWidget = widget; |
414 | + Methanal.Util.nodeInserted(widget); |
415 | + } |
416 | + return null; |
417 | + }); |
418 | + return d; |
419 | + }, |
420 | + |
421 | + |
422 | + /** |
423 | * Fetch the latest tab content from the server. |
424 | * |
425 | * If a fetch is already in progress it's result is discarded and a new |
426 | @@ -1801,16 +1935,7 @@ |
427 | |
428 | var d = self.callRemote('getContent'); |
429 | d.addCallback(function (widgetInfo) { |
430 | - return self.addChildWidgetFromWidgetInfo(widgetInfo); |
431 | - }); |
432 | - d.addCallback(function (widget) { |
433 | - if (abortFetch) { |
434 | - return widget.detach(); |
435 | - } else { |
436 | - self._setContent(widget.node); |
437 | - self._currentWidget = widget; |
438 | - Methanal.Util.nodeInserted(widget); |
439 | - } |
440 | + return self._setContentFromWidgetInfo(widgetInfo, abortFetch); |
441 | }); |
442 | return d; |
443 | }, |
444 | |
445 | === modified file 'methanal/test/test_widgets.py' |
446 | --- methanal/test/test_widgets.py 2010-03-16 22:01:24 +0000 |
447 | +++ methanal/test/test_widgets.py 2010-05-23 13:10:50 +0000 |
448 | @@ -5,7 +5,7 @@ |
449 | |
450 | from nevow import inevow |
451 | |
452 | -from methanal import widgets |
453 | +from methanal import widgets, errors |
454 | |
455 | |
456 | |
457 | @@ -24,12 +24,12 @@ |
458 | |
459 | def test_create(self): |
460 | """ |
461 | - Creating a L{TabView} widget initialises L{TabView._tabIDs} and |
462 | + Creating a L{TabView} widget initialises L{TabView._tabsByID} and |
463 | L{TabView.tabs} with the values originally specified. |
464 | """ |
465 | self.assertEquals( |
466 | - self.tabView._tabIDs, |
467 | - set([u'id1', u'id2', u'id3'])) |
468 | + sorted(self.tabView._tabsByID.keys()), |
469 | + [u'id1', u'id2', u'id3']) |
470 | self.assertEquals( |
471 | self.tabView.tabs, |
472 | self.tabs) |
473 | @@ -38,7 +38,7 @@ |
474 | def test_createGroup(self): |
475 | """ |
476 | Creating a L{methanal.widgets.TabView} widget initialises |
477 | - L{methanal.widgets.TabView._tabIDs}, L{methanal.widgets.TabView.tabs} |
478 | + L{methanal.widgets.TabView._tabsByID}, L{methanal.widgets.TabView.tabs} |
479 | and L{methanal.widgets.TabView._tabGroups} with the values originally |
480 | specified, tab groups are merged in and managed. |
481 | """ |
482 | @@ -49,71 +49,117 @@ |
483 | widgets.Tab(u'id5', u'Title 5', self.contentFactory)] |
484 | tabView = widgets.TabView(tabs) |
485 | self.assertEquals( |
486 | - tabView._tabIDs, |
487 | - set([u'id1', u'id2', u'id3', u'id4', u'id5'])) |
488 | + tabView.getTabIDs(), |
489 | + [u'id1', u'id2', u'id3', u'id4', u'id5']) |
490 | self.assertEquals( |
491 | tabView._tabGroups, |
492 | {u'group1': tabGroup}) |
493 | |
494 | |
495 | - def test_appendDuplicateTab(self): |
496 | - """ |
497 | - Appending a L{methanal.widgets.Tab} widget with an C{id} attribute that |
498 | - matches one already being managed raises C{ValueError}. |
499 | - """ |
500 | - self.assertRaises(ValueError, |
501 | - self.tabView.appendTab, self.tabView.tabs[0]) |
502 | - |
503 | - |
504 | - def test_appendTabs(self): |
505 | - """ |
506 | - Appending tabs on the server side manages them and invokes methods |
507 | + def test_updateTabs(self): |
508 | + """ |
509 | + Updating tabs on the server side manages them and invokes methods |
510 | on the client side to insert them. |
511 | """ |
512 | self.result = None |
513 | |
514 | def callRemote(methodName, *a): |
515 | - self.result = methodName == '_appendTabsFromServer' |
516 | + self.result = methodName == '_updateTabsFromServer' |
517 | |
518 | tab = widgets.Tab(u'id4', u'Title 4', self.contentFactory) |
519 | self.patch(self.tabView, 'callRemote', callRemote) |
520 | - self.tabView.appendTabs([tab]) |
521 | + self.tabView.updateTabs([tab]) |
522 | self.assertTrue(self.result) |
523 | - self.assertIn(u'id4', self.tabView._tabIDs) |
524 | + self.assertNotIdentical(self.tabView.getTab(u'id4'), None) |
525 | self.assertIdentical(self.tabView.tabs[-1], tab) |
526 | |
527 | - |
528 | - def test_appendDuplicateGroup(self): |
529 | - """ |
530 | - Appending a L{methanal.widgets.TabGroup} widget with an C{id} attribute |
531 | - that matches one already being managed raises C{ValueError}. |
532 | - """ |
533 | - tabGroup = widgets.TabGroup(u'group1', u'Group', tabs=[ |
534 | - widgets.Tab(u'id4', u'Title 4', self.contentFactory)]) |
535 | - self.tabView._manageGroup(tabGroup) |
536 | - self.assertRaises(ValueError, |
537 | - self.tabView.appendGroup, tabGroup) |
538 | - |
539 | - |
540 | - def test_appendGroup(self): |
541 | - """ |
542 | - Appending a group on the server site manages it, and all the tabs it |
543 | + replacementTab = widgets.Tab(u'id1', u'New title', self.contentFactory) |
544 | + oldTab = self.tabView.getTab(u'id1') |
545 | + self.tabView.updateTabs([replacementTab]) |
546 | + self.assertIdentical(self.tabView.getTab(u'id1'), replacementTab) |
547 | + self.assertNotIn(oldTab, self.tabView.tabs) |
548 | + |
549 | + |
550 | + def test_updateGroup(self): |
551 | + """ |
552 | + Updating a group on the server site manages it, and all the tabs it |
553 | contains, and invokes methods on the client side to insert them. |
554 | """ |
555 | self.result = None |
556 | |
557 | def callRemote(methodName, *a): |
558 | - self.result = methodName == '_appendTabsFromServer' |
559 | + self.result = methodName == '_updateTabsFromServer' |
560 | |
561 | tab = widgets.Tab(u'id4', u'Title 4', self.contentFactory) |
562 | group = widgets.TabGroup(u'group1', u'Group', tabs=[tab]) |
563 | self.patch(self.tabView, 'callRemote', callRemote) |
564 | - self.tabView.appendGroup(group) |
565 | + self.tabView.updateGroup(group) |
566 | self.assertTrue(self.result) |
567 | - self.assertIn(u'id4', self.tabView._tabIDs) |
568 | - self.assertIn(u'group1', self.tabView._tabGroups) |
569 | + self.assertNotIdentical(self.tabView.getTab(u'id4'), None) |
570 | + self.assertNotIdentical(self.tabView.getGroup(u'group1'), None) |
571 | self.assertIdentical(self.tabView.tabs[-1], tab) |
572 | |
573 | + # Update a group, and add a new tab. |
574 | + newTab = widgets.Tab(u'id5', u'Title 5', self.contentFactory) |
575 | + replacementGroup = widgets.TabGroup( |
576 | + u'group1', u'New Group', tabs=[newTab]) |
577 | + self.tabView.updateGroup(replacementGroup) |
578 | + self.assertIdentical( |
579 | + self.tabView.getGroup(u'group1'), replacementGroup) |
580 | + self.assertNotIdentical(self.tabView.getTab(u'id5'), None) |
581 | + self.assertRaises( |
582 | + errors.InvalidIdentifier, self.tabView.getTab, u'id4') |
583 | + self.assertNotIn(tab, self.tabView.tabs) |
584 | + |
585 | + # Remove a tab from a group. |
586 | + self.tabView.removeTabs([newTab]) |
587 | + self.assertRaises( |
588 | + errors.InvalidIdentifier, self.tabView.getTab, u'id5') |
589 | + self.assertNotIn(newTab, self.tabView.getGroup(u'group1').tabs) |
590 | + |
591 | + |
592 | + def test_removeTabs(self): |
593 | + """ |
594 | + Removing tabs on the server side releases them and invokes methods on |
595 | + the client side to remove them. |
596 | + """ |
597 | + self.result = None |
598 | + |
599 | + def callRemote(methodName, *a): |
600 | + self.result = methodName == '_removeTabsFromServer' |
601 | + |
602 | + self.patch(self.tabView, 'callRemote', callRemote) |
603 | + tab = self.tabView.tabs[0] |
604 | + self.tabView.removeTabs([tab]) |
605 | + self.assertTrue(self.result) |
606 | + self.assertRaises( |
607 | + errors.InvalidIdentifier, self.tabView.getTab, tab.id) |
608 | + |
609 | + |
610 | + def test_invalidRemove(self): |
611 | + """ |
612 | + Trying to remove an unmanaged tab results in C{ValueError} being |
613 | + raised. |
614 | + """ |
615 | + # Try a new tab. |
616 | + self.assertRaises(ValueError, |
617 | + self.tabView.removeTabs, [ |
618 | + widgets.Tab(u'id99', u'Title 1', self.contentFactory)]) |
619 | + |
620 | + # Try dupe an ID. |
621 | + self.assertRaises(ValueError, |
622 | + self.tabView.removeTabs, [ |
623 | + widgets.Tab(u'id1', u'Title 1', self.contentFactory)]) |
624 | + |
625 | + |
626 | + def test_repr(self): |
627 | + """ |
628 | + L{methanal.widgets.TabView} has an accurate string representation. |
629 | + """ |
630 | + self.assertEquals( |
631 | + repr(self.tabView), |
632 | + "<TabView topLevel=False tabs=%r>" % self.tabs) |
633 | + |
634 | |
635 | |
636 | class StaticTabTests(unittest.TestCase): |
637 | @@ -140,6 +186,21 @@ |
638 | self.assertEquals(tab.contentFactory(), content * 2) |
639 | |
640 | |
641 | + def test_repr(self): |
642 | + """ |
643 | + L{methanal.widgets.Tab} (and by extension L{methanal.widgets.StaticTab}) |
644 | + has an accurate string representation. |
645 | + """ |
646 | + tab = widgets.StaticTab( |
647 | + id=u'id', |
648 | + title=u'Title', |
649 | + content=u'A content.') |
650 | + self.assertEquals( |
651 | + repr(tab), |
652 | + "<StaticTab id=u'id' title=u'Title' selected=False group=None>") |
653 | + |
654 | + |
655 | + |
656 | |
657 | class TabGroupTests(unittest.TestCase): |
658 | """ |
659 | @@ -152,7 +213,40 @@ |
660 | tabs = [ |
661 | widgets.Tab(u'id1', u'Title 1', None), |
662 | widgets.Tab(u'id2', u'Title 2', None)] |
663 | - tabGroup = widgets.TabGroup(u'id', u'title', tabs=tabs) |
664 | + tabGroup = widgets.TabGroup(u'id', u'Title', tabs=tabs) |
665 | self.assertEquals( |
666 | inevow.IAthenaTransportable(tabGroup).getInitialArguments(), |
667 | - [u'id', u'title', [u'id1', u'id2']]) |
668 | + [u'id', u'Title', [u'id1', u'id2']]) |
669 | + |
670 | + |
671 | + def test_mergeGroups(self): |
672 | + """ |
673 | + L{methanal.widgets.TabGroup.mergeGroups} will merge two |
674 | + L{methanal.widgets.TabGroup}s together, preferring the metadata of the |
675 | + second specified group. |
676 | + """ |
677 | + tabs = [ |
678 | + widgets.Tab(u'id1', u'Title 1', None), |
679 | + widgets.Tab(u'id2', u'Title 2', None)] |
680 | + tabGroup1 = widgets.TabGroup(u'id', u'Title', tabs=tabs) |
681 | + tabs = [ |
682 | + widgets.Tab(u'id3', u'Title 3', None)] |
683 | + tabGroup2 = widgets.TabGroup(u'id', u'Hello', tabs=tabs) |
684 | + |
685 | + newGroup = widgets.TabGroup.mergeGroups(tabGroup1, tabGroup2) |
686 | + self.assertEquals(newGroup.id, u'id') |
687 | + self.assertEquals(newGroup.title, u'Hello') |
688 | + self.assertEquals(newGroup.tabs, tabGroup1.tabs + tabGroup2.tabs) |
689 | + |
690 | + |
691 | + def test_repr(self): |
692 | + """ |
693 | + L{methanal.widgets.TabGroup} has an accurate string representation. |
694 | + """ |
695 | + tabs = [ |
696 | + widgets.Tab(u'id1', u'Title 1', None), |
697 | + widgets.Tab(u'id2', u'Title 2', None)] |
698 | + tabGroup = widgets.TabGroup(u'id', u'Title', tabs=tabs) |
699 | + self.assertEquals( |
700 | + repr(tabGroup), |
701 | + "<TabGroup id=u'id' title=u'Title' tabs=%r>" % tabs) |
702 | |
703 | === modified file 'methanal/themes/methanal-base/methanal-tab-view.html' |
704 | --- methanal/themes/methanal-base/methanal-tab-view.html 2010-01-03 21:23:44 +0000 |
705 | +++ methanal/themes/methanal-base/methanal-tab-view.html 2010-05-23 13:10:50 +0000 |
706 | @@ -2,6 +2,6 @@ |
707 | <ul class="methanal-tab-labels" id="labels"> |
708 | <li id="throbber" class="methanal-tab-label" style="padding: 0 2px;"><img src="/static/Methanal/images/main-throbber.gif" style="height: 10px; width: 10px;" /></li> |
709 | </ul> |
710 | - <div style="clear: both;" nevow:render="tabContents" /> |
711 | + <div id="contents" style="clear: both;" nevow:render="tabContents" /> |
712 | <div class="terminator"></div> |
713 | </div> |
714 | |
715 | === modified file 'methanal/widgets.py' |
716 | --- methanal/widgets.py 2010-03-14 01:24:31 +0000 |
717 | +++ methanal/widgets.py 2010-05-23 13:10:50 +0000 |
718 | @@ -11,6 +11,8 @@ |
719 | |
720 | from twisted.internet.defer import maybeDeferred |
721 | from twisted.python.components import registerAdapter |
722 | +from twisted.python.versions import Version |
723 | +from twisted.python.deprecate import deprecated |
724 | |
725 | from axiom.item import SQLAttribute |
726 | |
727 | @@ -28,6 +30,7 @@ |
728 | ObjectSelectInput, SimpleForm, FormInput, LiveForm, SubmitAction, |
729 | ActionButton, ActionContainer) |
730 | from methanal.model import Value |
731 | +from methanal.errors import InvalidIdentifier |
732 | |
733 | |
734 | |
735 | @@ -658,8 +661,8 @@ |
736 | the fragment part of the current URL to track which tab is selected, |
737 | this behaviour supercedes L{Tab.selected}. |
738 | |
739 | - @type _tabIDs: C{set} |
740 | - @ivar _tabIDs: Collection of unique tab IDs currently being managed. |
741 | + @type _tabsByID: C{dict} mapping C{unicode} to L{methanal.widgets.Tab} |
742 | + @ivar _tabsByID: Mapping of unique tab IDs to tabs currently being managed. |
743 | |
744 | @type _tabGroups: C{dict} mapping C{unicode} to |
745 | L{methanal.widgets.TabGroup} |
746 | @@ -675,7 +678,7 @@ |
747 | @param tabs: Tab or tab groups to manage. |
748 | """ |
749 | super(TabView, self).__init__(**kw) |
750 | - self._tabIDs = set() |
751 | + self._tabsByID = {} |
752 | self._tabGroups = {} |
753 | self.tabs = [] |
754 | self.topLevel = topLevel |
755 | @@ -689,76 +692,170 @@ |
756 | self._manageTab(tabOrGroup) |
757 | |
758 | |
759 | + def __repr__(self): |
760 | + return '<%s topLevel=%r tabs=%r>' % ( |
761 | + type(self).__name__, |
762 | + self.topLevel, |
763 | + self.tabs) |
764 | + |
765 | + |
766 | def _manageGroup(self, group): |
767 | """ |
768 | Begin managing a L{methanal.widgets.TabGroup}. |
769 | |
770 | - Tabs contained in a group are B{not} managed, L{_manageTab} should be |
771 | - called for each tab. |
772 | - |
773 | - @raise ValueError: If the group's identifier matches one that is |
774 | - already being managed. |
775 | + Tabs contained in a group are B{not} managed automatically, |
776 | + L{_manageTab} should be called for each tab. |
777 | """ |
778 | - if group.id in self._tabGroups: |
779 | - raise ValueError('%r is a duplicate tab group identifier in %r' % ( |
780 | - group.id, self)) |
781 | self._tabGroups[group.id] = group |
782 | |
783 | |
784 | - def _manageTab(self, tab): |
785 | - """ |
786 | - Begin managing a L{Tab} widget. |
787 | - |
788 | - @raise ValueError: If the tab's identifier matches one that is already |
789 | - being managed. |
790 | - """ |
791 | - if tab.id in self._tabIDs: |
792 | + def _manageTab(self, tab, overwrite=False): |
793 | + """ |
794 | + Begin managing a L{methanal.widgets.Tab} widget. |
795 | + |
796 | + If a tab identifier is already being managed it is released before |
797 | + managing the new widget. |
798 | + """ |
799 | + if tab.id in self._tabsByID: |
800 | + self._releaseTab(self.getTab(tab.id)) |
801 | + self._tabsByID[tab.id] = tab |
802 | + self.tabs.append(tab) |
803 | + group = self._tabGroups.get(tab.group) |
804 | + if group is not None: |
805 | + group._manageTab(tab) |
806 | + |
807 | + |
808 | + def _releaseTab(self, tab): |
809 | + """ |
810 | + Stop managing a L{methanal.widgets.Tab} widget. |
811 | + """ |
812 | + if tab not in self.tabs: |
813 | raise ValueError( |
814 | - '%r is a duplicate tab identifier in %r' % (tab.id, self)) |
815 | - self._tabIDs.add(tab.id) |
816 | - self.tabs.append(tab) |
817 | - |
818 | - |
819 | + '%r is not managed by %r' % (tab.id, self)) |
820 | + del self._tabsByID[tab.id] |
821 | + self.tabs.remove(tab) |
822 | + group = self._tabGroups.get(tab.group) |
823 | + if group is not None: |
824 | + group._releaseTab(tab) |
825 | + |
826 | + |
827 | + def getTab(self, id): |
828 | + """ |
829 | + Get a L{methanal.widgets.Tab} by its unique identifier. |
830 | + """ |
831 | + tab = self._tabsByID.get(id) |
832 | + if tab is None: |
833 | + raise InvalidIdentifier( |
834 | + u'%r is not a valid tab identifier in %r' % (id, self)) |
835 | + return tab |
836 | + |
837 | + |
838 | + def getTabIDs(self): |
839 | + """ |
840 | + Get a C{list} of all the tab IDs in the group. |
841 | + """ |
842 | + return [tab.id for tab in self.tabs] |
843 | + |
844 | + |
845 | + def getGroup(self, id): |
846 | + """ |
847 | + Get a L{methanal.widgets.TabGroup} by its unique identifier. |
848 | + """ |
849 | + group = self._tabGroups.get(id) |
850 | + if group is None: |
851 | + raise InvalidIdentifier( |
852 | + u'%r is not a valid group identifier in %r' % (id, self)) |
853 | + return group |
854 | + |
855 | + |
856 | + @deprecated(Version('methanal', 0, 2, 1)) |
857 | def appendTab(self, tab): |
858 | """ |
859 | - Append a L{Tab} widget. |
860 | + Append a L{methanal.widgets.Tab} widget. |
861 | |
862 | @return: A C{Deferred} that fires when the widget has been inserted on |
863 | the client side. |
864 | """ |
865 | - return self.appendTabs([tab]) |
866 | - |
867 | - |
868 | - def appendTabs(self, tabs): |
869 | + return self.updateTabs([tab]) |
870 | + |
871 | + |
872 | + def updateTabs(self, tabs, tabsToRemove=None): |
873 | """ |
874 | - Append many L{methanal.widgets.Tab} widgets. |
875 | - |
876 | - All tab widgets are passed to the client side and appended there, and |
877 | - added to the relevant groups. |
878 | - |
879 | - @return: A C{Deferred} that fires when the widgets have been inserted on |
880 | + Update many L{methanal.widgets.Tab} widgets. |
881 | + |
882 | + All tab widgets are passed to the client side, updated there (or |
883 | + appended if they didn't previously exist) and added to the relevant |
884 | + groups. Use L{methanal.widgets.TabGroup.mergeGroups} to simulate adding |
885 | + to an existing group. |
886 | + |
887 | + @return: A C{Deferred} that fires when the widgets have been updated on |
888 | the client side. |
889 | """ |
890 | for tab in tabs: |
891 | self._manageTab(tab) |
892 | tab.setFragmentParent(self) |
893 | - return self.callRemote('_appendTabsFromServer', tabs, self._tabGroups) |
894 | - |
895 | - |
896 | - def appendGroup(self, group): |
897 | - """ |
898 | - Append a L{methanal.widgets.TabGroup} and its tabs. |
899 | + |
900 | + tabIDsToRemove = [] |
901 | + if tabsToRemove is not None: |
902 | + tabIDsToRemove = [tab.id for tab in tabsToRemove] |
903 | + |
904 | + return self.callRemote( |
905 | + '_updateTabsFromServer', tabs, tabIDsToRemove, self._tabGroups) |
906 | + |
907 | + |
908 | + appendTabs = deprecated(Version('methanal', 0, 2, 1))(updateTabs) |
909 | + |
910 | + |
911 | + def removeTabs(self, tabs): |
912 | + """ |
913 | + Remove many L{methanal.widgets.Tab} widgets. |
914 | + |
915 | + Empty groups, caused by removing all contained tabs, are removed too. |
916 | + |
917 | + @return: A C{Deferred} that fires when the widget has been removed on |
918 | + the client side. |
919 | + """ |
920 | + tabIDs = [] |
921 | + for tab in tabs: |
922 | + tabIDs.append(tab.id) |
923 | + self._releaseTab(tab) |
924 | + |
925 | + return self.callRemote('_removeTabsFromServer', tabIDs, self._tabGroups) |
926 | + |
927 | + |
928 | + def updateGroup(self, group): |
929 | + """ |
930 | + Update a L{methanal.widgets.TabGroup} and its tabs. |
931 | + |
932 | + Tabs specified in C{group} will replace those previously specified by |
933 | + another group of the same name. |
934 | |
935 | @return: A C{Deferred} that fires when the widgets have been inserted on |
936 | the client side. |
937 | """ |
938 | + try: |
939 | + oldGroup = self.getGroup(group.id) |
940 | + except InvalidIdentifier: |
941 | + oldGroup = None |
942 | + |
943 | + tabsToRemove = [] |
944 | + if oldGroup is not None: |
945 | + tabsToRemove = map( |
946 | + self.getTab, |
947 | + set(oldGroup.getTabIDs()) - set(group.getTabIDs())) |
948 | + for tab in tabsToRemove: |
949 | + self._releaseTab(tab) |
950 | + |
951 | self._manageGroup(group) |
952 | - return self.appendTabs(group.tabs) |
953 | + return self.updateTabs(group.tabs, tabsToRemove) |
954 | + |
955 | + |
956 | + appendGroup = deprecated(Version('methanal', 0, 2, 1))(updateGroup) |
957 | |
958 | |
959 | def getInitialArguments(self): |
960 | return [ |
961 | - dict.fromkeys(self._tabIDs, True), |
962 | + dict.fromkeys(self._tabsByID.iterkeys(), True), |
963 | self._tabGroups, |
964 | self.topLevel] |
965 | |
966 | @@ -774,7 +871,7 @@ |
967 | |
968 | |
969 | |
970 | -class TabGroup(record('id title tabs')): |
971 | +class TabGroup(object): |
972 | """ |
973 | Visually group labels of L{methanal.widgets.Tab}s together. |
974 | |
975 | @@ -792,15 +889,59 @@ |
976 | |
977 | jsClass = u'Methanal.Widgets.TabGroup' |
978 | |
979 | - def __init__(self, *a, **kw): |
980 | - super(TabGroup, self).__init__(*a, **kw) |
981 | - for tab in self.tabs: |
982 | + def __init__(self, id, title, tabs): |
983 | + self.id = id |
984 | + self.title = title |
985 | + self.tabs = [] |
986 | + for tab in tabs: |
987 | + self._manageTab(tab) |
988 | + |
989 | + |
990 | + def __repr__(self): |
991 | + return '<%s id=%r title=%r tabs=%r>' % ( |
992 | + type(self).__name__, |
993 | + self.id, |
994 | + self.title, |
995 | + self.tabs) |
996 | + |
997 | + |
998 | + def _manageTab(self, tab): |
999 | + """ |
1000 | + Manage a L{methanal.widgets.Tab} widget. |
1001 | + """ |
1002 | + if tab not in self.tabs: |
1003 | + self.tabs.append(tab) |
1004 | tab.group = self.id |
1005 | |
1006 | |
1007 | + def _releaseTab(self, tab): |
1008 | + """ |
1009 | + Stop managing a L{methanal.widgets.Tab} widget. |
1010 | + """ |
1011 | + if tab in self.tabs: |
1012 | + self.tabs.remove(tab) |
1013 | + tab.group = None |
1014 | + |
1015 | + |
1016 | + def getTabIDs(self): |
1017 | + """ |
1018 | + Get a C{list} of all the tab IDs in the group. |
1019 | + """ |
1020 | + return [tab.id for tab in self.tabs] |
1021 | + |
1022 | + |
1023 | + @classmethod |
1024 | + def mergeGroups(cls, old, new): |
1025 | + """ |
1026 | + Merge two L{methanal.widgets.TabGroup}s together. |
1027 | + """ |
1028 | + return cls(new.id, new.title, old.tabs + new.tabs) |
1029 | + |
1030 | + |
1031 | + # IAthenaTransportable |
1032 | + |
1033 | def getInitialArguments(self): |
1034 | - tabIDs = [tab.id for tab in self.tabs] |
1035 | - return [self.id, self.title, tabIDs] |
1036 | + return [self.id, self.title, self.getTabIDs()] |
1037 | |
1038 | |
1039 | |
1040 | @@ -839,6 +980,15 @@ |
1041 | self.group = group |
1042 | |
1043 | |
1044 | + def __repr__(self): |
1045 | + return '<%s id=%r title=%r selected=%r group=%r>' % ( |
1046 | + type(self).__name__, |
1047 | + self.id, |
1048 | + self.title, |
1049 | + self.selected, |
1050 | + self.group) |
1051 | + |
1052 | + |
1053 | def getInitialArguments(self): |
1054 | return [getArgsDict(self)] |
1055 | |
1056 | @@ -887,6 +1037,10 @@ |
1057 | return tag[self.getContent()] |
1058 | |
1059 | |
1060 | + def updateRemoteContent(self): |
1061 | + pass |
1062 | + |
1063 | + |
1064 | |
1065 | class DynamicTab(Tab): |
1066 | """ |
1067 | @@ -909,3 +1063,9 @@ |
1068 | being discarded and a new fetch occurring. |
1069 | """ |
1070 | jsClass = u'Methanal.Widgets.DemandTab' |
1071 | + |
1072 | + def updateRemoteContent(self): |
1073 | + """ |
1074 | + Force the remote content to be updated. |
1075 | + """ |
1076 | + return self.callRemote('_setContentFromWidgetInfo', self.getContent()) |