|
|
|
| [1] | The YUI website: http://developer.yahoo.com/yui/ |
For this part of the tutorial, you should have some basic understanding of what megrok.navigation does. Check the megrok.navigation page on PyPI.
The menus created by megrok.navigation are viewletmanagers, and the items are viewlets, so if you define a menu, you are in fact defining a viewletmanager. Create a new source file 'menu.py', and add this code
import grok
from megrok import navigation
class MainMenu(navigation.Menu):
grok.name('main-menu')
Edit 'layout.pt', and replace
<div id="header" role="navigation">Navigation will go here!</div>
with
<div id="header" role="navigation" tal:content="structure provider:main-menu"></div>
Edit 'app.py' by adding some imports and changing the Index view definition to
from megrok import navigation
from menu import MainMenu
class Index(layout.Page):
navigation.sitemenuitem(MainMenu, 'Home', order=-1)
We created a menu, called it 'main-menu', told the layout template to render it at the top of the page, and added the main index view as the home link, setting its order to -1 to have it rendered first (the default order is 0).
Restart your server and take a look.
Not exactly state-of-the-art, is it? The default templates of megrok.navigation just render an unordered list (<ul>), and there is no CSS or javascript in place to do anything fancy. A YUI menu requires a bit more than just an <ul>, so we need to tell megrok.navigation to use a different template.
megrok.template makes this an easy job. Go back to 'menu.py' and change the menu definition, and add 2 page templates
from megrok import pagetemplate from hurry import yui class MainMenu(navigation.Menu): grok.name('main-menu') def update(self): super(MainMenu, self).update() yui.menu.need() id = 'main-menu' cssClass='yuimenubar yuimenubarnav' cssItemClass='yuimenubaritem' cssItemLabelClass='yuimenubaritemlabel' class MenuTemplate(pagetemplate.PageTemplate): grok.template('menu') pagetemplate.view(navigation.interfaces.IMenu) class ItemTemplate(pagetemplate.PageTemplate): grok.template('item') pagetemplate.view(navigation.interfaces.IMenuItem)
The menu definition overrides the update method to tell hurry.yui that it need() s its menu resource library. Don't forget to call the super classes update when you invoke need() here, as a menu is a viewletmanager, and as such is required to call its viewlets' update method. Create a directory 'menu_templates', and add a file 'menu.pt' with this content:
<div tal:attributes="id viewletmanager/id|default;class viewletmanager/cssClass"
tal:define="viewlets viewletmanager/viewlets"
tal:condition="viewlets">
<div class="bd">
<ul class="first-of-type">
<tal:repeat tal:repeat='viewlet viewlets'
tal:replace='structure viewlet/render'/>
</ul>
</div>
</div>
Also, add a file 'item.pt' with this content:
<li tal:attributes="class viewletmanager/cssItemClass"
tal:define="submenu viewlet/subMenu | nothing">
<a href="#"
tal:attributes="href viewlet/link; class viewletmanager/cssItemLabelClass">
<img tal:condition="viewlet/icon | nothing" tal:attributes="src viewlet/icon;
title viewlet/title"/>
<span tal:content="viewlet/title">Title</span></a>
<tal:block condition="submenu"
tal:replace="structure provider:${submenu}">sub menu items</tal:block>
</li>
All we did now was making sure the menu is rendered in a YUI compatible manner, using DIV and UL elements, and setting the right CSS classes. When overriding the megrok.navigation templates, you should bear two things in mind:
So far, so good, but the menu still looks the same. Let's get YUI to do it's magic with it! We need to add a piece of javascript to the page. It's common practice to do that at the bottom of the page. We could just add it to the layout, but there is a more elegant way: add a viewletmanager to the bottom of the layout, and add scripts as viewlets, so the layout doesn't have to know what is needed.
Add this line to 'layout.pt' just above the </body> tag:
<tal:block tal:replace="structure provider:scripts"/>
Edit 'layout.py', and add this:
class Scripts(grok.ViewletManager):
grok.context(Interface)
Now, in 'menu.py' add this:
from layout import Scripts
from zope.interface import Interface
class MenuScript(grok.Viewlet):
grok.viewletmanager(Scripts)
grok.context(Interface)
grok.template('script')
And add a file 'script.pt' in the 'menu_templates' dir containing
<script type="text/javascript">
YAHOO.util.Event.onContentReady("main-menu", function () {
var oMenuBar = new YAHOO.widget.MenuBar("main-menu", {
autosubmenudisplay: true,
hidedelay: 750,
lazyload: true });
oMenuBar.render();
});
</script>
Now there is still one important part missing: the default YUI skin called 'sam'. First of all, you need to activate it by adding an attribute 'class' with the value 'yui-skin-sam' to the <body> tag in 'layout.pt':
<body class="yui-skin-sam">
But the skin CSS also needs to be loaded. A quick way is to load the entire sam skin by adding a yui.sam.need() call in the layout's update. But this loads the styles for all YUI components. That makes up for quite a hefty file, whereas we're only interested in the menu part. YUI also provides separate skin files for each component. We'll use such a separate file, but again, we're going to do it using a viewletmanager.
Add this to the 'layout.pt's <head> element just between the <title> and <link> elements:
<tal:block tal:replace="structure provider:stylesheets"/>
And add this class to 'layout.py':
class StyleSheets(grok.ViewletManager):
grok.context(Interface)
Now edit 'menu.py' to add
from layout import StyleSheets
class MenuStylesheet(grok.Viewlet):
grok.viewletmanager(StyleSheets)
grok.context(Interface)
template = grok.PageTemplate('<link rel="stylesheet" type="text/css" '
'tal:attributes="href context/++resource++yui/menu/assets/skins/sam/menu.css" />')
We took a shortcut here in order not to have to create a separate .pt file for 1 line of code.
Now restart the server and take a look.
Looks a lot better, doesn't it?
Let's have some more fun. Let's pretend we are creating (yet another) blogging app. Add a new menu in 'menu.py':
class BlogMenu(navigation.ContentMenu):
grok.name('blog-menu')
def update(self):
super(BlogMenu, self).update()
yui.menu.need()
def getContent(self):
return grok.getSite().values()
def getTitle(self, entry):
return entry.title
id = 'blog-menu'
cssClass='yuimenu yuimenunav'
cssItemClass='yuimenuitem'
cssItemLabelClass='yuimenuitemlabel'
and add this directive to the MainMenu class definition
navigation.submenu('blog-menu', 'Blogs')
Here we created a 'ContentMenu': a menu linking to site content, which is a specialization of a normal menu. A standard menu links to other views of the current context (navigation.menuitem) or of the site (navigation.sitemenuitem). A ContentMenu links to views (typically the 'index' view) of arbitrary (but locatable) objects you choose in the getContent() method. We then add the Blog menu as a submenu (navigation.submenu) to the Main menu so that it will appear as a typical desktop application style drop-down menu.
Create 'blog.py' containing
import grok
from zope.interface import Interface
from zope import schema
from megrok import layout, navigation
from urllib import quote_plus
from menu import BlogMenu
from cgi import escape
class IBlogEntry(Interface):
title = schema.TextLine(title=u'title')
text = schema.Text(title=u'Body')
class BlogEntry(grok.Model):
grok.implements(IBlogEntry)
@property
def htmltext(self):
return escape(self.text).replace('\n', '<br/>');
class BlogIndex(layout.Page):
grok.name('index')
class Add(layout.AddForm):
navigation.sitemenuitem(BlogMenu, order=-1)
grok.title('Add a Blog Entry')
grok.context(grok.Application)
form_fields = grok.Fields(IBlogEntry)
@grok.action('Add entry')
def Add(self, **data):
entry = BlogEntry()
self.applyData(entry, **data)
grok.getSite()[quote_plus(entry.title)] = entry
self.redirect(self.url(entry))
Create a directory called 'blog_templates and add 'blogindex.pt', containing
<h1 tal:content="context/title"></h1>
<p tal:content="structure context/htmltext"></p>
Restart the server and have a look. Add a few blog entries to check it out.
Well done, you implemented a YUI menu in Grok!
Of course, you can skin the menu all you like, but that's beyond the scope of this tutorial, just check the YUI docs on how to do that.
AJAX stands for Asynchronous JavaScript And XML. A non-AJAX page works synchronously, in the sense that you request a page, read it, click a link or submit a form, and the entire page is refreshed. Repeat ad infinitum. However, in most cases a large part of the new page is exactly the same as the previous page: headers, footers, menus and perhaps more. So the server is actually doing a lot of work for something that only changed partly. And a lot of network bandwidth is wasted too. If only you could just update the needed parts...
This is exactly what AJAX was invented for, and why it is said to be asynchronous: you do something on the page which triggers a request to the server that only returns some data or a part of the page, which is then dynamically inserted in the page. Multiple requests would be pending, and the responses need not arrive in the order of the requests. The request and the update are usually done using JavaScript, although technically other scripting languages could be used, too (including Python!).
That explains the A and the J, but what about XML? In the initial definition it was specified that XML was to be used for data interchange between client and server, and for that purpose the XMLHttpRequest object was introduced. Since then however, it has become clear that XML is not a requirement: you could as well send plain text, pre-formatted HTML or format your data as JSON (JavaScript Object Notation, see below); it all depends on how you handle the data. So the X stands for whatever rather than XML, nowadays.
A quick note on JSON: JSON is a very lightweight way of defining an object in plain text. It is much easier to decode or produce JSON data than XML data: e.g. calling eval('<json-text>') in JavaScript will result in a JavaScript object. JSON also very closely matches a text-representation of a Python dictionary (if there really IS a difference?).
The first thing we're going to do with AJAX is using the YUI TabView, which can use AJAX under the hood to load the contents of the tabs, so you can have a feeling of what AJAX can do without having to do too much 'plumbing' yourself. We'll change the way a blog entry is presented with tabs: one for viewing the entry, and one for viewing meta data (Dublin Core).
A TabView can be used to display contents that are in the HTML to start with, or as we want, it can load the contents from an 'external source': a separate view on our server. However, in that case it also needs the YUI Connection Manager to be loaded, so we shouldn't forget that!
We'll go for the pre-formatted HTML approach, so the current 'blogindex.pt' has to be displayed inside the View tab, but using the BlogIndex will cause the layout to be loaded too, so we'll have to make a separate view that doesn't show layout and change the original 'blogindex.pt' to display the TabView.
Copy 'blogindex.pt' to 'blogview.pt'. Create a grok View called BlogView in 'blog.py', and specify that BlogIndex now needs yui.tabview and yui.connection like this:
from megrok import resource
from hurry import yui
class BlogIndex(layout.Page):
grok.name('index')
resource.include(yui.tabview)
resource.include(yui.connection)
class BlogView(grok.View):
grok.name('view')
Note that we derive from grok.View, and not layout.Page!
We also used another way of specifying needed resources. Instead of using the need() function, you can also use megrok.resource's include directive. There's a catch, however: this directive only works with objects you actually traverse to: in this case we traverse over our app object to a BlogEntry object and finally to the BlogIndex view. In case of our Layout object, this won't work, because you never traverse to your layout, it is merely looked up in code when needed by a Page to which you did traverse. So be careful with this!
Now edit 'blogindex.pt'
<div id="blog-view" class="yui-navset">
<ul class="yui-nav">
</ul>
<div class="yui-content">
</div>
</div>
We need some JavaScript to hook it up and load the appropriate CSS. Add a script and stylesheet viewlet to the file 'blog.py':
from layout import Scripts, StyleSheets
class BlogIndexScript(grok.Viewlet):
grok.view(BlogIndex)
grok.viewletmanager(Scripts)
grok.context(Interface)
grok.template('script')
class BlogIndexStylesheet(grok.Viewlet):
grok.viewletmanager(StyleSheets)
grok.context(Interface)
template = grok.PageTemplate('<link rel="stylesheet" type="text/css" '
'tal:attributes="href '
'context/++resource++yui/tabview/assets/skins/sam/tabview.css" />')
and the associated 'script.pt' template in the 'blog-templates' directory:
<tal:tag tal:replace='structure string:<script type="text/javascript">' />
var myTabs = new YAHOO.widget.TabView("blog-view");
myTabs.addTab(new YAHOO.widget.Tab(
{label:'View',
dataSrc: '<tal:tag tal:replace="python:view.url(context, 'view')"/>',
active: true})
);
myTabs.addTab(new YAHOO.widget.Tab(
{label:'Meta Data',
dataSrc: '<tal:tag tal:replace="python:view.url(context, 'meta')"/>'})
);
<tal:tag tal:replace="structure string:</script>" />
You might be wondering why we didn't just use normal <script> elements. This is because we need some dynamic parts in the script: the urls to the views. ZPT will not parse anything that is placed in <script> (and <style>) elements because typically that is application code that should not be parsed. Maybe that wasn't such a good idea after all, but this is how ZPT works, so we have to deal with it. Another workaround would be to add a funtion to the viewlet that returns the JavaScript as a string and use the tal:content expression on the script tag. Or overwrite the render method and do it all by yourself.
The only thing left now is the meta-data view in 'blog.py'.
class MetaDataView(grok.View):
grok.name('meta')
and 'metadataview.pt' in 'blog-templates'
<span tal:define="created context/zope:created;
modified context/zope:modified;
formatter python:request.locale.dates.getFormatter('dateTime')">
<dl>
<span tal:condition="created" > <dt>Created</dt>
<dd tal:content="python:formatter.format(created)"></dd></span>
<span tal:condition="modified" > <dt>Modified</dt>
<dd tal:content="python:formatter.format(modified)"></dd></span>
</dl>
</span>
We're using the Zope implementation of Dublin Core, which is a de facto industry standard for meta-data in document-oriented systems. I won't go any further into that, as it has nothing to do with JavaScript or AJAX.
Now restart the server and check it out.
Each time you switch tabs, the content is loaded from the server. You can also change the behaviour, such that the contents are only loaded once, by setting the 'cacheData' configuration option of the tab to true. But we specifically don't want that behaviour, or the rest of the tutorial won't work!
In 'blog.py' we'll need to define an edit form, but we can't just use a megrok.layout.EditForm, because we don't want the layout to appear again in our tab. We'll solve this here by using an ordinary grok.EditForm to start with; further below we'll see another trick, using layers and skins.
class Edit(grok.EditForm):
grok.context(IBlogEntry)
form_fields = grok.Fields(IBlogEntry)
@grok.action('Save Entry')
def Save(self, **data):
self.applyData(self.context, **data)
self.redirect(self.url(self.context))
This will work as expected, but of course it will post the data and refresh the entire page. We'll need to hack the form's onsubmit handler to stop doing the default, and do our bidding. There's one catch: the form doesn't exist when the page is loaded: you're looking at the View tab, and the form is only loaded when the Edit tab is selected, so we can only rewire the submit code once the contents of the Edit tab changed.
add this code within the <script> element of the blog's 'script.pt' (below the myTabs.addTab() function that adds the 'Meta Data' tab):
myTabs.addTab(new YAHOO.widget.Tab(
{label:'Edit',
dataSrc: '<tal:tag tal:replace="python:view.url(context, 'edit')"/>'})
);
var submitcallback = {
success: function(o) {myTabs.selectTab(0);},
failure: function(o) {editTab.content='Oops something went quite wrong...'}
};
var editTab = myTabs.getTab(2);
editTab.addListener('contentChange',
function(e)
{
var formObject = document.forms[0];
YAHOO.util.Event.on(formObject,'submit', function (e) {
YAHOO.util.Event.stopEvent(e);
YAHOO.util.Connect.setForm(formObject);
YAHOO.util.Connect.asyncRequest('POST', formObject.action, submitcallback);
});
}
);
We first added the new tab. We then defined a callback object containing functions in case of a success or failure. In case of success, we want to return to the view tab, otherwise we set the contents to an error message.
NOTE: we don't take a successful submit with invalid data into account here!
Then we attached a listener to the contentChange event of our Edit tab, which rewires the submit handler not to do only the default submit (YAHOO.util.Event.stopEvent(e)), but also loads the form data from the form and sends it with an asynchronous request.
Try it out!
Of course, this is a rather quick 'n dirty way to handle it, as there still are quite a lot of situations that aren't handled correctly, but it basically works, and that's what this tutorial is about.
We created an EditForm to be used inside the TabView. You can browse to it, but you won't have the layout, since it is not derived from megrok.layout.Page, but if we do that, it would render the entire layout inside the TabView again.
However, we can solve this with layers and skins! Remember that rendering a view derived from megrok.layout.Page will look up a megrok.layout.Layout for the current layer and render itself in it. I specifically mentioned this for this reason: we can override the layout in another skin to just render the page and no other html around it.
So let's define a layer and skin and a layout as part of this skin in 'layout.py':
from zope.publisher.interfaces.browser import IDefaultBrowserLayer
class IAJAXLayer(IDefaultBrowserLayer):
grok.skin('ajax')
class AJAXLayout(layout.Layout):
grok.layer(IAJAXLayer)
grok.context(Interface)
template=grok.PageTemplate('<tal:tag tal:replace="structure view/content"/>')
We derive the layer from IDefaultBrowserLayer and not from IBrowserRequest, so that all views can still be looked up in both skins, since Grok registers all views to IDefaultBrowserLayer (which is derived from IBrowserRequest) by default.
The template just renders the Page content and nothing else.
Edit 'blog.py' and make BlogView and MetaDataView derive from layout.Page and Edit from layout.EditForm.
Restart the server, and try the edit form with and without the skin: if your url was
http://localhost:8080/yuidemo/Well+hello+there/edit
before, which should return the edit form in the layout without the TabView, change it to
http://localhost:8080/++skin++ajax/yuidemo/Well+hello+there/edit
to activate the skin, and you'll get just the form markup as text.
If you now go back to the index page. You'll see the problem I mentioned before: the urls for the Tabs still refer to the default skin views and rerender the layout inside the Tab. There's only one problem: there is no generic way (yet?) to generate a url to a different skin! How can we know it's an AJAX request then? Well, AJAX requests will have the 'X-Requested-With' HTTP header set to 'XMLHttpRequest', and we can apply the skin based on that!
Add this to 'layout.py':
from zope.app.publication.interfaces import IBeforeTraverseEvent
from zope.interface import alsoProvides
@grok.subscribe(grok.Application, IBeforeTraverseEvent)
def handle(obj, event):
if event.request.getHeader('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest':
alsoProvides(event.request, IAJAXLayer)
Now the index page will look and behave again as it did before, but the separate pages will also work. To make it more clear, you can now make the other views available in the menubar in 'menu.py':
class ActionMenu(navigation.Menu):
grok.name('actions-menu')
id = 'action-menu'
cssClass='yuimenu yuimenunav'
cssItemClass='yuimenuitem'
cssItemLabelClass='yuimenuitemlabel'
and also add
navigation.submenu('actions-menu', 'Actions', order=2)
to the MainMenu definition.
Finally, add navigation.menuitem directives to the view, meta-data and edit views in 'blog.py' like:
navigation.menuitem(ActionMenu, 'View', order=-1)
Don't forget to add
from menu import ActionMenu
Well, restart the server, and tell us: what do you think about that!?
To demonstrate JSON, we'll be using flash messages. Flash messages are short messages that you can 'send' to the user from somewhere inside your code -- typically when a new object was created or something similar -- and then show them on the page rendered next. See the z3c.flashmessage package on PyPI for more info.
We'll be periodically checking the server for new messages, and display them in a Panel that will popup.
Grok 1.0 uses z3c.flashmessage by default and registers a z3c.flashmessage.interfaces.IMessageSource utility A grok.View has a flash method for this purpose, however, in megrok.layout v0.9, megrok.layout.Page is not derived from grok.View, so it does not have the flash. So we will need to brew our own function for that.
We'll put all our message code in a 'message.py' file:
import grok
from zope.interface import Interface
from hurry import yui
from layout import StyleSheets, Scripts
if you're using Grok v1.0, also define this:
from zope import component
from z3c.flashmessage.interfaces import IMessageReceiver, IMessageSource
def flash( message, type='message'):
source = component.getUtility(IMessageSource, name='session')
source.send(message, type)
This is the flash function to be used whenever we see fit. It just gets the IMessageSource utility that is registered by Grok, and sends the message to it. You can specify a type to categorize your messages.
class MessagesScript(grok.Viewlet):
grok.viewletmanager(Scripts)
grok.context(Interface)
grok.template('script')
def update(self):
yui.json.need()
yui.container.need()
yui.dragdrop.need()
@property
def messageurl(self):
return self.view.url(grok.getSite(), 'messages')
class PanelStylesheet(grok.Viewlet):
grok.viewletmanager(StyleSheets)
grok.context(Interface)
template = grok.PageTemplate('<link rel="stylesheet" type="text/css" '
'tal:attributes="href '
'context/++resource++yui/container/assets/skins/sam/container.css" />')
Here we loaded the script and tell hurry.yui that it needs to load JSON, Container and DragDrop from the YUI library. DragDrop is needed if we want our messagebox to be movable. There is also a messageurl property method that returns the url to our JSON view which we will define later.
We also loaded the container CSS file provided with YUI.
These are the contents of the 'script.pt' file (in the 'message_templates' dir):
<div id="messages">
<div class="hd">Messages</div>
<div class="bd"></div>
<div class="ft"></div>
</div>
<tal:tag tal:replace="structure string:<script type='text/javascript'>" />
messagePanel = new YAHOO.widget.Panel("messages",
{ constraintoviewport:true,
visible: false,
context: ['body', 'bl', 'bl']
});
messagePanel.render();
function getMessages()
{
var callback = {
success: function(o) {
var messages = YAHOO.lang.JSON.parse(o.responseText);
for (i in messages)
{
dl = document.createElement('dl');
var msg = messages[i];
dl.innerHTML='<dt>'+ msg.type+'</dt><dd>'+msg.message+'</dd>';
o.argument.appendToBody(dl);
o.argument.show();
}
},
timeout: 3000,
argument: this
};
YAHOO.util.Connect.asyncRequest('GET',
'<tal:tag tal:replace="viewlet/messageurl"/>',
callback,
null);
}
YAHOO.lang.later(5000, messagePanel, getMessages, null, true);
messagePanel.hideEvent.subscribe(function(type, args, panel)
{
panel.setBody('')
},
messagePanel);
<tal:tag tal:replace="structure string:</script>" />
We first defined the HTML code that defines a default YUI Module (of which a Panel is a specilization). We then created a Panel object that refers to that code, and we set some configuration options, amongst which that it should be hidden at the start.
Then we defined the getMessages() function that queries the server for new messages, and if there are, we parse them into a JavaScript object using the YAHOO.lang.JSON.parse function, cast them into HTML and append them to the Panel body and then we show the Panel.
We then wired this function to be called every 5 seconds.
Finally, we attached an event handler to the hideEvent of the Panel, that clears the Panel's body.
Now we are still missing one bit: the JSON view:
class JSONMessages(grok.JSON):
grok.context(grok.Application)
def messages(self):
receiver = component.getUtility(IMessageReceiver)
return [{'message':m.message, 'type':m.type} for m in receiver.receive()]
A grok.JSON view makes all its (public) methods traversable, and those methods should return something that can be turned into JSON data with the simplejson Python package. In our case, it's an array of dictionaries containing the message data and the type of message.
Now we have everything in place to display messages, but we don't send any. Let's edit 'blog.py':
for grok 1.0, add this
from message import flash
In the Add method of the AddForm, add this before the redirect:
self.flash("New blog entry '%s' added!" % entry.title, type='blog')
And in the Save method of the EditForm, add this before the redirect:
self.flash("Blog entry '%s' updated!" % self.context.title, type='blog')
for Grok 1.0, omit the 'self.'. So, whenever we edit or add a blog, a message will be sent.
Restart the server, refresh your page and have a blast!
By now you should have a rather thorough understanding on how you can use the YUI Library in a Grok project. The main focus of YUI is not AJAX itself, but providing user interface components that can be easily reused and that work on all browsers. It uses AJAX for some of its functionality, but it is only a small fraction of it.
There are other packages out there (JQuery is very popular one for instance) that perhaps are better suited for pure AJAX applications, but if you understand how to use YUI, you won't have a hard time incorporating other libraries into your projects.
Source code for the final project can be downloaded from http://github.com/jmichiel/YUIdemo
© Copyright 2007-2008, The Grok Community
Hosting provided by Quintagroup
