Document Actions

Search forms with zope formlib and batching

Filed Under:

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:

<page
    name="product-search"
    class=".search.Search"
    for="Products.CMFCore.interfaces.ISiteRoot"
    permission="zope.Public"
    allowed_interface=".search.ISearchForm"
    />

Same for a results page:

<page
    name="results"
    class=".results.Results"
    template="products.pt"
    for="*"
    permission="zope.Public"
    allowed_interface=".results.IResults"
    />

And the main page showing search plus results:

<page
    name="main-search"
    template="searchpage.pt"
    for="Products.CMFCore.interfaces.ISiteRoot"
    permission="zope.Public"
    allowed_interface=".search.ISearchForm"
    />

Use something like CMFPlone/skins/plone_content/document_view.pt as base and add the structure of the search and results subpages:

<html ...
      metal:use-macro="here/main_template/macros/master">
<body>

<metal:main fill-slot="main">
...

        <div tal:replace="structure provider:plone.belowcontenttitle" />

        <form action="" method="get">
          <div tal:replace="structure context/product-search" />
        </form>

        <div tal:replace="structure context/results" />

        <div tal:replace="structure provider:plone.belowcontentbody" />
...
</metal:main>

</body>
</html>

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:

<adapter
    factory="zope.formlib.form.default_subpage_template"
    name="default.subform" />
<adapter
    factory=".search.subpage_template"
    name="customer.search" />

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:

<metal:form use-macro="view/original_template/macros/form">
  <div metal:fill-slot="extra_info">
    <h2 tal:content="view/description">A nice description</h2>
  </div>
</metal:form>

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:

<div
    tal:condition="view/has_results">
  <table class="listing">
  ...
  </table>
  <tal:batch replace="structure view/batching" />
</div>

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!