Search forms with zope formlib and batching
Combine a search form and a results page by using zope.formlib and plone.app.content.batching.
In this tutorial we want to have a search form on our web site. After a visitor fills in the form he should see a page with results. But on that same page he should see the search form again, with his previous values filled in. On top of that, the results should be batched, displaying only the first ten results and having links to the next pages. On Plone 3.0 you can do this by combining zope.formlib and plone.app.content.batching.
Some details are left out, like explaining how files and classes are called and where they are located (most are in the browser/ directory), or not showing some methods whose goal and code are hopefully clear enough from their name. The idea is not to make this too long and I am failing at that already. :-) If I omitted too much for your taste and you have a specific question, just ask.
Building search forms
We start with a browser view. On the top we need some imports:
from zope import schema from zope.interface import Interface from zope.interface import implements from zope.formlib import form from Products.Five.formlib import formbase
With our search form we want to search for some searchable text and for Products of a specific Brand. Brand is a content type we (Zest Software) made for a customer. A Product is another content type, which is added to a Brand. We made a vocabulary for this which maps the title of a Brand (shown in the form) to the path where this Brand is found.
We define an Interface for our search form. Very important: let the parameters match the names of the catalog indices that you want to search on:
class ISearchForm(Interface): """These are the fields of our search form. """ path = schema.Choice(title = _(u'Brand'), vocabulary=u"customer.brand", description = _(u'Brand, mark, formula.'), required = False, ) SearchableText = schema.TextLine(title = _(u'Search word'), description = _(u'Search for specific words.'), required = False, )
For schema.Choice the default widget is a SelectWidget or RadioWidget. As empty value it displays "(no value)" in the drop down box. At least for a Dutch web site this is not very nice, as there is no translation. You can provide an own version of that message by doing something like this:
from customer.product import customerMessageFactory as _ from zope.app.form.browser.itemswidgets import SelectWidget SelectWidget._messageNoValue = _("vocabulary-missing-single-value-for-edit", "Selecteer indien gewenst een waarde.")
Or do this in English and use i18ndude of course.
Now write a class that inherits from:
- Products.Five.formlib.formbase.PageForm to have a complete page.
- Products.Five.formlib.formbase.SubPageForm to get a partial page.
In our case the search form will be only part of a page, so we choose the second option:
class Search(formbase.SubPageForm): implements(ISearchForm) form_fields = form.FormFields(ISearchForm) label = _(u"Working independently") description = _(u"You are looking for a product for:")
Add an action button to that class:
@form.action(_(u"Search")) def action_search(self, action, data): """Perform search. """
On a SubFormPage this can actually be empty (pass): the surrounding form tag that you have to supply yourself takes care of redirecting. For a complete FormPage you would at least need something like this:
self.request.response.redirect(target)
where target should be a page with search results.
Search form and results on one page
Hook up your browser view in configure.zcml. We only register this view for the Site Root in this case:
Same for a results page:
And the main page showing search plus results:
Use something like CMFPlone/skins/plone_content/document_view.pt as base and add the structure of the search and results subpages:
... ...
Overwriting the search form template
If the default template from zope.formlib does not give you what you want, you overwrite it. Configure some named adapters in zcml:
The factory for the first adapter is in zope.formlib. Our own alternative factory is this:
from zope.formlib import interfaces, namedtemplate from zope.app.pagetemplate import ViewPageTemplateFile subpage_template = namedtemplate.NamedTemplateImplementation( ViewPageTemplateFile('searchform.pt'), interfaces.ISubPageForm)
Let your class know it should use the new template:
class Search(formbase.SubPageForm): ... original_template = namedtemplate.NamedTemplate('default.subform') template = namedtemplate.NamedTemplate('customer.search')
Now write the template, using the original template via the original_template attribute from the view. In this case we add the form description in a slot:
A nice description
Remembering form values
The form now submits to itself. Currently its values are forgotten. So we fix that:
from zope.formlib.form import setUpWidgets class Search(formbase.SubPageForm): ... # We set an empty prefix, otherwise we would end up with # 'form.SearchableText', etc, in the request while the code here and in # results.py expects 'SearchableText'. prefix = '' def setUpWidgets(self, ignore_request=False): """From zope.formlib.form.Formbase. Difference: pass extra data=self.request.form. """ self.adapters = {} self.widgets = setUpWidgets( self.form_fields, self.prefix, self.context, self.request, form=self, adapters=self.adapters, ignore_request=ignore_request, data=data)
While we are here, let's fix a possible unicode error. There is a difference between getting a value from the request or from request.form when unicode is involved:
>>> request.form.get('SearchableText') u'\xff' >>> request.get('SearchableText') '\xc3\xbf'
In the method zope.app.form.browser.textwidgets.TextWidget._getFormInput() the value is fetched from the request and not request.form. So you get a UnicodeDecodeError. We need to guard against that:
def setUpWidgets(self, ignore_request=False): """From zope.formlib.form.Formbase. """ self.adapters = {} fieldnames = [x.__name__ for x in self.form_fields] data = {} for key in fieldnames: value = self.request.form.get(key) if value is not None and not value == u'': data[key] = value self.request[key] = value self.widgets = setUpWidgets( self.form_fields, self.prefix, self.context, self.request, form=self, adapters=self.adapters, ignore_request=ignore_request, data=data)
Batching on the results page
The search form is working fine now. So we move on the the search results. The results template can be quite simple: a ul with an li for every search result if that is enough for you. The only interesting part is batching:
...
So we need a view with attributes has_results and batching. This is the base of the browser view:
from plone.app.content.batching import Batch class IResults(Interface): """List of Products. """ has_results = Attribute("The search has matching results.") items = Attribute("List of Products") batch = Attribute("Batch of brains") url = Attribute("URL for this context") class Results(BrowserView): implements(IResults) # We want to use the batching.pt file from plone.app.content, but # that has a few drawbacks so we copied it ourselves. batching = ViewPageTemplateFile('batching.pt')
We overwrite it because it has one flaw. It has a question mark where we want an ampersand, so we can add more options in the url. It was:
tal:attributes="href string:${view/url}?pagenumber=${batch/previouspage}&sort_on=${request/sort_on|string:getObjPositionInParent}">
and we change that to:
tal:attributes="href string:${view/url}&pagenumber=${batch/previouspage}&sort_on=${request/sort_on|string:getObjPositionInParent}">
This change means we can add our filled in search terms (path, SearchableText) to the url method of the view:
@property def url(self): """Base url, needed by the batching template.""" url = self.context.absolute_url() terms = ["%s=%s" % (key, value) for key, value in self.search_filter.items()] query = '&'.join(terms) return url + '?' + query
We add a search_filter to our view that gets the filled in values from our search form:
@property @memoize def search_filter(self): """Construct search filter. Only add valid search terms from the request. """ context = aq_inner(self.context) form = self.request.form search_filter = {} for key in ['path', 'SearchableText']: value = form.get(key) if value is not None and not value == u'': search_filter[key] = value # When viewing a Brand, add its path to the filter. if search_filter.get('path') is None: if IBrand.providedBy(context): search_filter['path'] = '/'.join(context.getPhysicalPath()) return search_filter
Incidentally, our search form and results will also be used on the view of a Brand, which is why the last few lines were added.
Our view needs a batch property:
@property def batch(self): """Batch of Products (brains). """ context = aq_inner(self.context) # We only search for Products. We need to copy the # search_filter as we need it original contents somewhere # else, without the portal_type. search_filter = self.search_filter.copy() search_filter['portal_type'] = 'Product' catalog = getToolByName(context, 'portal_catalog') brains = catalog.searchResults(search_filter) batch = Batch(items=brains, pagesize=10, pagenumber=self.pagenumber, navlistsize=5) return batch
We use that batch in the shown batching.pt template, but also in our own view, as we do not want to pass brains to our template, but a nice list of dictionaries:
def items(self): """List of Products. Here we get some info for all the Products that are in the current page of the batch. """ batch = self.batch items = [] for brain in batch: product = brain.getObject() info = dict( url=product.absolute_url(), title=product.Title(), isbn=product.getIsbn(), ) items.append(info) return items
There you have it: a search form with batched results on the same page. And all thanks to the authors of zope.formlib and plone.app.content.batching!