Basic refactoring of standard search functionality.
Move everything to loops.expert, making loops.search obsolete.
This commit is contained in:
parent
12de2376ac
commit
ad8c236aa5
12 changed files with 819 additions and 14 deletions
|
@ -108,9 +108,9 @@ class NodeView(BaseView):
|
||||||
subMacros=[node_macros.macros['page_actions'],
|
subMacros=[node_macros.macros['page_actions'],
|
||||||
i18n_macros.macros['language_switch']])
|
i18n_macros.macros['language_switch']])
|
||||||
if self.globalOptions('expert.quicksearch'):
|
if self.globalOptions('expert.quicksearch'):
|
||||||
from loops.expert.browser.search import searchMacrosTemplate
|
from loops.expert.browser.search import search_template
|
||||||
cm.register('top_actions', 'top_quicksearch', name='multi_actions',
|
cm.register('top_actions', 'top_quicksearch', name='multi_actions',
|
||||||
subMacros=[searchMacrosTemplate.macros['quicksearch']],
|
subMacros=[search_template.macros['quicksearch']],
|
||||||
priority=20)
|
priority=20)
|
||||||
cm.register('portlet_left', 'navigation', title='Navigation',
|
cm.register('portlet_left', 'navigation', title='Navigation',
|
||||||
subMacro=node_macros.macros['menu'])
|
subMacro=node_macros.macros['menu'])
|
||||||
|
|
|
@ -474,7 +474,7 @@
|
||||||
<include package=".media" />
|
<include package=".media" />
|
||||||
<include package=".organize" />
|
<include package=".organize" />
|
||||||
<include package=".rest" />
|
<include package=".rest" />
|
||||||
<include package=".search" />
|
<!--<include package=".search" />-->
|
||||||
<include package=".security" />
|
<include package=".security" />
|
||||||
<include package=".system" />
|
<include package=".system" />
|
||||||
<include package=".versioning" />
|
<include package=".versioning" />
|
||||||
|
|
|
@ -16,7 +16,31 @@
|
||||||
<browser:page
|
<browser:page
|
||||||
name="search.html"
|
name="search.html"
|
||||||
for="loops.interfaces.INode"
|
for="loops.interfaces.INode"
|
||||||
class="loops.expert.browser.search.SearchResults"
|
class="loops.expert.browser.search.QuickSearchResults"
|
||||||
permission="zope.View" />
|
permission="zope.View" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="search"
|
||||||
|
for="loops.interfaces.IConcept
|
||||||
|
zope.publisher.interfaces.browser.IBrowserRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="loops.expert.browser.search.Search"
|
||||||
|
permission="zope.View"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<browser:page
|
||||||
|
name="listConceptsForComboBox.js"
|
||||||
|
for="loops.interfaces.ILoopsObject"
|
||||||
|
class="loops.expert.browser.search.Search"
|
||||||
|
attribute="listConcepts"
|
||||||
|
permission="zope.View"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<browser:page
|
||||||
|
name="searchresults.html"
|
||||||
|
for="loops.interfaces.ILoopsObject"
|
||||||
|
class="loops.expert.browser.search.SearchResults"
|
||||||
|
permission="zope.View"
|
||||||
|
/>
|
||||||
|
|
||||||
</configure>
|
</configure>
|
||||||
|
|
|
@ -20,6 +20,57 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<metal:search define-macro="search"
|
||||||
|
i18n:domain="loops">
|
||||||
|
<div id="search"
|
||||||
|
tal:define="macros item/search_macros;
|
||||||
|
idPrefix string:${view/itemNum}.search;
|
||||||
|
formId string:$idPrefix.form;
|
||||||
|
resultsId string:$idPrefix.results;
|
||||||
|
submitted request/search.submitted|nothing">
|
||||||
|
<h1 tal:attributes="class string:content-$level;
|
||||||
|
ondblclick item/openEditWindow"
|
||||||
|
tal:content="item/title">
|
||||||
|
Search
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div metal:define-macro="search_form" class="searchForm">
|
||||||
|
<fieldset class="box">
|
||||||
|
<form action="." method="post" id="1.search.form"
|
||||||
|
tal:attributes="id formId">
|
||||||
|
<input type="hidden" name="search.submitted" value="yes" />
|
||||||
|
<input type="hidden" name="search.resultsId" value="1.results"
|
||||||
|
tal:attributes="value resultsId" />
|
||||||
|
<table cellpadding="3">
|
||||||
|
<tal:block repeat="search_param python:
|
||||||
|
['type', 'text', 'concept', 'state']">
|
||||||
|
<metal:row use-macro="macros/search_row" />
|
||||||
|
</tal:block>
|
||||||
|
<tr tal:condition="nothing">
|
||||||
|
<td colspan="2">
|
||||||
|
<input type="button" value="Add concept filter" class="button" />
|
||||||
|
<input type="button" value="Add attribute filter" class="button" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td colspan="3"><br />
|
||||||
|
<input type="submit" name="button.search" value="Search" class="submit"
|
||||||
|
i18n:attributes="value"
|
||||||
|
tal:attributes="xx_onclick python:
|
||||||
|
item.submitReplacing(resultsId, formId, view)" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<tal:results condition="request/search.submitted|nothing">
|
||||||
|
<metal:results use-macro="item/search_macros/results" />
|
||||||
|
</tal:results>
|
||||||
|
</div>
|
||||||
|
</metal:search>
|
||||||
|
|
||||||
<div metal:define-macro="search_results" id="search.results"
|
<div metal:define-macro="search_results" id="search.results"
|
||||||
tal:define="item nocall:view">
|
tal:define="item nocall:view">
|
||||||
<fieldset class="box">
|
<fieldset class="box">
|
||||||
|
@ -87,5 +138,240 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div metal:define-macro="search_row" id="1.1.row"
|
||||||
|
tal:define="rowNum item/rowNum;
|
||||||
|
basicIdPrefix idPrefix;
|
||||||
|
idPrefix string:$idPrefix.$rowNum;
|
||||||
|
namePrefix string:search.$rowNum;
|
||||||
|
param search_param | item/searchParam"
|
||||||
|
tal:attributes="id string:$idPrefix.row">
|
||||||
|
<div metal:use-macro="macros/?param" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<metal:text define-macro="type">
|
||||||
|
<tr>
|
||||||
|
<td metal:use-macro="macros/minus"/>
|
||||||
|
<td colspan="3">
|
||||||
|
<h2 i18n:translate="">Type(s) to search for</h2>
|
||||||
|
</td>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<label for="text"
|
||||||
|
tal:attributes="for string:$idPrefix.text">
|
||||||
|
<span i18n:translate="">Type</span>:</label>
|
||||||
|
<select name="text"
|
||||||
|
tal:define="name string:$namePrefix.text;
|
||||||
|
value request/?name|nothing"
|
||||||
|
tal:attributes="name name;
|
||||||
|
id string:$idPrefix.text;">
|
||||||
|
<tal:types repeat="type item/typesForSearch">
|
||||||
|
<option value="loops:*"
|
||||||
|
i18n:translate=""
|
||||||
|
tal:attributes="value type/token;
|
||||||
|
selected python: value == type.token"
|
||||||
|
tal:content="type/title">Topic</option>
|
||||||
|
</tal:types>
|
||||||
|
</select>
|
||||||
|
<input type="button" value="+"
|
||||||
|
title="Add type"
|
||||||
|
tal:condition="nothing" />
|
||||||
|
</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</metal:text>
|
||||||
|
|
||||||
|
|
||||||
|
<metal:text define-macro="text">
|
||||||
|
<tr>
|
||||||
|
<td metal:use-macro="macros/minus"/>
|
||||||
|
<td colspan="3">
|
||||||
|
<h2 i18n:translate="">Text-based search</h2>
|
||||||
|
</td>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" value="yes"
|
||||||
|
tal:define="name string:$namePrefix.title"
|
||||||
|
tal:attributes="name name;
|
||||||
|
id string:$idPrefix.title;
|
||||||
|
checked request/?name|not:submitted|string:yes" />
|
||||||
|
<label for="title"
|
||||||
|
i18n:translate=""
|
||||||
|
tal:attributes="for string:$idPrefix.title">Title</label>
|
||||||
|
<input type="checkbox" value="yes"
|
||||||
|
tal:define="name string:$namePrefix.full"
|
||||||
|
tal:attributes="name name;
|
||||||
|
id string:$idPrefix.full;
|
||||||
|
checked request/?name|nothing" />
|
||||||
|
<label for="full"
|
||||||
|
i18n:translate=""
|
||||||
|
tal:attributes="for string:$idPrefix.full">Full text</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label for="text"
|
||||||
|
tal:attributes="for string:$idPrefix.text">
|
||||||
|
<span i18n:translate="">Search text</span>:</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text"
|
||||||
|
tal:define="name string:$namePrefix.text"
|
||||||
|
tal:attributes="name name;
|
||||||
|
id string:$idPrefix.text;
|
||||||
|
value request/?name|nothing" />
|
||||||
|
<input type="button" value="+"
|
||||||
|
title="Add search word"
|
||||||
|
tal:condition="nothing" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</metal:text>
|
||||||
|
|
||||||
|
|
||||||
|
<metal:text define-macro="concept">
|
||||||
|
<tr>
|
||||||
|
<td metal:use-macro="macros/minus"/>
|
||||||
|
<td colspan="3">
|
||||||
|
<h2 i18n:translate="">Search via related concepts</h2>
|
||||||
|
</td>
|
||||||
|
<tr tal:repeat="type item/presetSearchTypes">
|
||||||
|
<tal:preset define="rowNum item/rowNum;
|
||||||
|
idPrefix string:$basicIdPrefix.$rowNum;
|
||||||
|
namePrefix string:search.$rowNum;">
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<span i18n:translate="">Type</span>:
|
||||||
|
<b tal:content="type/title" i18n:translate="" />
|
||||||
|
<input type="hidden" name="type" value=""
|
||||||
|
tal:attributes="name string:$namePrefix.type;
|
||||||
|
value type/token" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label for="text"
|
||||||
|
tal:attributes="for string:$idPrefix.text">
|
||||||
|
<span i18n:translate="">Concept for Search</span>:</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="text_selected"
|
||||||
|
tal:define="name string:$namePrefix.text_selected;
|
||||||
|
value request/?name|nothing"
|
||||||
|
tal:attributes="name name;
|
||||||
|
id string:$idPrefix.text;">
|
||||||
|
<tal:concepts repeat="concept python: item.conceptsForType(type['token'])">
|
||||||
|
<option tal:attributes="value concept/token;
|
||||||
|
selected python: value == concept['token']"
|
||||||
|
i18n:translate=""
|
||||||
|
tal:content="concept/title">Zope Corp</option>
|
||||||
|
</tal:concepts>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tal:preset>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<label for="type"
|
||||||
|
tal:attributes="for string:$idPrefix.type">
|
||||||
|
<span i18n:translate="">Type</span>:</label>
|
||||||
|
<select name="type"
|
||||||
|
tal:define="name string:$namePrefix.type;
|
||||||
|
global conceptTypeValue request/?name|string:"
|
||||||
|
tal:attributes="name name;
|
||||||
|
id string:$idPrefix.type;
|
||||||
|
value conceptTypeValue;
|
||||||
|
onChange string:setConceptTypeForComboBox('$idPrefix.type', '$idPrefix.text')">
|
||||||
|
<tal:types repeat="type item/conceptTypesForSearch">
|
||||||
|
<option value="loops:*"
|
||||||
|
i18n:translate=""
|
||||||
|
tal:attributes="value type/token;
|
||||||
|
selected python: conceptTypeValue == type.token"
|
||||||
|
tal:content="type/title">Topic</option>
|
||||||
|
</tal:types>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label for="text"
|
||||||
|
tal:attributes="for string:$idPrefix.text">
|
||||||
|
<span i18n:translate="">Concept for Search</span>:</label>
|
||||||
|
<input type="text" name="text"
|
||||||
|
tal:condition="nothing"
|
||||||
|
tal:attributes="name string:$namePrefix.text;
|
||||||
|
id string:$idPrefix.text;" />
|
||||||
|
<input type="button" value="+"
|
||||||
|
title="Add type"
|
||||||
|
tal:condition="nothing" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<tal:combo tal:define="dummy item/initDojo;
|
||||||
|
name string:$namePrefix.text;
|
||||||
|
value request/?name|nothing;
|
||||||
|
concept python:
|
||||||
|
value and view.getObjectForUid(value);
|
||||||
|
displayValue python:
|
||||||
|
concept and concept.title or u''">
|
||||||
|
<div dojoType="dojox.data.QueryReadStore" jsId="conceptSearch"
|
||||||
|
url="listConceptsForComboBox.js?searchType="
|
||||||
|
tal:attributes="url
|
||||||
|
string:listConceptsForComboBox.js?searchType=$conceptTypeValue">
|
||||||
|
</div>
|
||||||
|
<input dojoType="dijit.form.FilteringSelect" store="conceptSearch"
|
||||||
|
autoComplete="False" labelAttr="name" style="height: 16px"
|
||||||
|
name="concept.search.text" id="concept.search.text"
|
||||||
|
tal:attributes="name name;
|
||||||
|
id string:$idPrefix.text;
|
||||||
|
displayedValue displayValue" />
|
||||||
|
</tal:combo>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</metal:text>
|
||||||
|
|
||||||
|
|
||||||
|
<metal:text define-macro="state"
|
||||||
|
tal:define="stateDefs item/statesDefinitions;
|
||||||
|
deftype string:resource"
|
||||||
|
tal:condition="stateDefs">
|
||||||
|
<tr>
|
||||||
|
<td metal:use-macro="macros/minus"/>
|
||||||
|
<td colspan="3">
|
||||||
|
<h2 i18n:translate="">Restrict to objects with certain states</h2>
|
||||||
|
</td>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<th i18n:translate="">Workflow</th>
|
||||||
|
<th colspan="2"
|
||||||
|
i18n:translate="">States</th>
|
||||||
|
</tr>
|
||||||
|
<tr tal:repeat="def stateDefs">
|
||||||
|
<td></td>
|
||||||
|
<td valign="top"
|
||||||
|
tal:content="def/name"
|
||||||
|
i18n:translate=""></td>
|
||||||
|
<td colspan="2">
|
||||||
|
<tal:states repeat="state def/states">
|
||||||
|
<tal:state define="name string:state.$deftype.${def/name};
|
||||||
|
value state/name">
|
||||||
|
<input type="checkbox"
|
||||||
|
tal:attributes="name string:$name:list;
|
||||||
|
value value;
|
||||||
|
checked python:
|
||||||
|
value in item.selectedStates.get(name, ());
|
||||||
|
id string:$name.$value"
|
||||||
|
/> <label tal:content="state/title"
|
||||||
|
i18n:translate=""
|
||||||
|
tal:attributes="for string:$name.$value" />
|
||||||
|
|
||||||
|
</tal:state>
|
||||||
|
</tal:states>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</metal:text>
|
||||||
|
|
||||||
|
|
||||||
|
<td metal:define-macro="minus">
|
||||||
|
<input type="button" value="−"
|
||||||
|
title="Remove search parameter"
|
||||||
|
tal:condition="python:
|
||||||
|
param not in ['type', 'text', 'concept', 'state']" />
|
||||||
|
</td>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,5 +1,5 @@
|
||||||
#
|
#
|
||||||
# Copyright (c) 2009 Helmut Merz helmutm@cy55.de
|
# Copyright (c) 2011 Helmut Merz helmutm@cy55.de
|
||||||
#
|
#
|
||||||
# This program is free software; you can redistribute it and/or modify
|
# This program is free software; you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
@ -28,22 +28,26 @@ from zope.app.pagetemplate import ViewPageTemplateFile
|
||||||
from zope.cachedescriptors.property import Lazy
|
from zope.cachedescriptors.property import Lazy
|
||||||
from zope.traversing.api import getName, getParent
|
from zope.traversing.api import getName, getParent
|
||||||
|
|
||||||
|
from cybertools.stateful.interfaces import IStateful, IStatesDefinition
|
||||||
|
from loops.browser.common import BaseView
|
||||||
from loops.browser.node import NodeView
|
from loops.browser.node import NodeView
|
||||||
|
from loops.common import adapted, AdapterBase
|
||||||
from loops.expert.concept import ConceptQuery, FullQuery
|
from loops.expert.concept import ConceptQuery, FullQuery
|
||||||
|
from loops.interfaces import IResource
|
||||||
from loops.organize.personal.browser.filter import FilterView
|
from loops.organize.personal.browser.filter import FilterView
|
||||||
from loops import util
|
from loops import util
|
||||||
from loops.util import _
|
from loops.util import _
|
||||||
|
|
||||||
|
|
||||||
searchMacrosTemplate = ViewPageTemplateFile('search.pt')
|
search_template = ViewPageTemplateFile('search.pt')
|
||||||
|
|
||||||
|
|
||||||
class SearchResults(NodeView):
|
class QuickSearchResults(NodeView):
|
||||||
""" Provides results listing """
|
""" Provides results listing """
|
||||||
|
|
||||||
@Lazy
|
@Lazy
|
||||||
def search_macros(self):
|
def search_macros(self):
|
||||||
return self.controller.getTemplateMacros('search', searchMacrosTemplate)
|
return self.controller.getTemplateMacros('search', search_template)
|
||||||
|
|
||||||
@Lazy
|
@Lazy
|
||||||
def macro(self):
|
def macro(self):
|
||||||
|
@ -64,3 +68,250 @@ class SearchResults(NodeView):
|
||||||
result = fv.apply(result)
|
result = fv.apply(result)
|
||||||
result.sort(key=lambda x: x.title)
|
result.sort(key=lambda x: x.title)
|
||||||
return self.viewIterator(result)
|
return self.viewIterator(result)
|
||||||
|
|
||||||
|
|
||||||
|
class Search(BaseView):
|
||||||
|
|
||||||
|
maxRowNum = 0
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def search_macros(self):
|
||||||
|
return self.controller.getTemplateMacros('search', search_template)
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def macro(self):
|
||||||
|
return self.search_macros['search']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rowNum(self):
|
||||||
|
""" Return the rowNum to be used for identifying the current search
|
||||||
|
parameter row.
|
||||||
|
"""
|
||||||
|
n = self.request.get('loops.rowNum', 0)
|
||||||
|
if n: # if given directly we don't use the calculation
|
||||||
|
return n
|
||||||
|
n = (self.maxRowNum or self.request.get('loops.maxRowNum', 0)) + 1
|
||||||
|
self.maxRowNum = n
|
||||||
|
return n
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def presetSearchTypes(self):
|
||||||
|
""" Return a list of concept type info dictionaries (see BaseView)
|
||||||
|
that should be displayed on a separate search parameter row.
|
||||||
|
"""
|
||||||
|
#return ITypeManager(self.context).listTypes(include=('search',))
|
||||||
|
return self.listTypesForSearch(include=('search',))
|
||||||
|
|
||||||
|
def conceptsForType(self, token):
|
||||||
|
result = ConceptQuery(self).query(type=token)
|
||||||
|
fv = FilterView(self.context, self.request)
|
||||||
|
result = fv.apply(result)
|
||||||
|
result.sort(key=lambda x: x.title)
|
||||||
|
noSelection = dict(token='none', title=u'not selected')
|
||||||
|
return [noSelection] + [dict(title=adapted(o, self.languageInfo).title,
|
||||||
|
token=util.getUidForObject(o))
|
||||||
|
for o in result]
|
||||||
|
|
||||||
|
def initDojo(self):
|
||||||
|
self.registerDojo()
|
||||||
|
cm = self.controller.macros
|
||||||
|
jsCall = ('dojo.require("dojo.parser");'
|
||||||
|
'dojo.require("dijit.form.FilteringSelect");'
|
||||||
|
'dojo.require("dojox.data.QueryReadStore");')
|
||||||
|
cm.register('js-execute', jsCall, jsCall=jsCall)
|
||||||
|
|
||||||
|
def listConcepts(self, filterMethod=None):
|
||||||
|
""" Used for dijit.FilteringSelect.
|
||||||
|
"""
|
||||||
|
request = self.request
|
||||||
|
request.response.setHeader('Content-Type', 'text/plain; charset=UTF-8')
|
||||||
|
title = request.get('name')
|
||||||
|
if title == '*':
|
||||||
|
title = None
|
||||||
|
types = request.get('searchType')
|
||||||
|
data = []
|
||||||
|
if title or types:
|
||||||
|
if title is not None:
|
||||||
|
title = title.replace('(', ' ').replace(')', ' ').replace(' -', ' ')
|
||||||
|
#title = title.split(' ', 1)[0]
|
||||||
|
if not types:
|
||||||
|
types = ['loops:concept:*']
|
||||||
|
if not isinstance(types, (list, tuple)):
|
||||||
|
types = [types]
|
||||||
|
for type in types:
|
||||||
|
result = self.executeQuery(title=title or None, type=type,
|
||||||
|
exclude=('hidden',))
|
||||||
|
fv = FilterView(self.context, self.request)
|
||||||
|
result = fv.apply(result)
|
||||||
|
for o in result:
|
||||||
|
if o.getLoopsRoot() == self.loopsRoot:
|
||||||
|
adObj = adapted(o, self.languageInfo)
|
||||||
|
if filterMethod is not None and not filterMethod(adObj):
|
||||||
|
continue
|
||||||
|
name = self.getRowName(adObj)
|
||||||
|
if title and title.endswith('*'):
|
||||||
|
title = title[:-1]
|
||||||
|
sort = ((title and name.startswith(title) and '0' or '1')
|
||||||
|
+ name.lower())
|
||||||
|
if o.conceptType is None:
|
||||||
|
raise ValueError('Concept Type missing for %r.' % name)
|
||||||
|
data.append({'label': self.getRowLabel(adObj, name),
|
||||||
|
'name': name,
|
||||||
|
'id': util.getUidForObject(o),
|
||||||
|
'sort': sort})
|
||||||
|
data.sort(key=lambda x: x['sort'])
|
||||||
|
if not title:
|
||||||
|
data.insert(0, {'label': '', 'name': '', 'id': ''})
|
||||||
|
json = []
|
||||||
|
for item in data[:100]:
|
||||||
|
json.append("{label: '%s', name: '%s', id: '%s'}" %
|
||||||
|
(item['label'], item['name'], item['id']))
|
||||||
|
json = "{identifier: 'id', items: [%s]}" % ', '.join(json)
|
||||||
|
#print '***', json
|
||||||
|
return json
|
||||||
|
|
||||||
|
def executeQuery(self, **kw):
|
||||||
|
return ConceptQuery(self).query(**kw)
|
||||||
|
|
||||||
|
def getRowName(self, obj):
|
||||||
|
return obj.getLongTitle()
|
||||||
|
|
||||||
|
def getRowLabel(self, obj, name=None):
|
||||||
|
if isinstance(obj, AdapterBase):
|
||||||
|
obj = obj.context
|
||||||
|
if name is None:
|
||||||
|
name = obj.title
|
||||||
|
return '%s (%s)' % (name, obj.conceptType.title)
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def statesDefinitions(self):
|
||||||
|
return [component.getUtility(IStatesDefinition, name=n)
|
||||||
|
for n in self.globalOptions('organize.stateful.resource', ())]
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def selectedStates(self):
|
||||||
|
result = {}
|
||||||
|
for k, v in self.request.form.items():
|
||||||
|
if k.startswith('state.') and v:
|
||||||
|
result[k] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
def submitReplacing(self, targetId, formId, view):
|
||||||
|
self.registerDojo()
|
||||||
|
return 'submitReplacing("%s", "%s", "%s"); return false;' % (
|
||||||
|
targetId, formId,
|
||||||
|
'%s/.target%s/@@searchresults.html' % (view.url, self.uniqueId))
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def results(self):
|
||||||
|
form = self.request.form
|
||||||
|
type = form.get('search.1.text', 'loops:*')
|
||||||
|
text = form.get('search.2.text')
|
||||||
|
if text is not None:
|
||||||
|
text = util.toUnicode(text, encoding='ISO8859-15') # IE hack!!!
|
||||||
|
useTitle = form.get('search.2.title')
|
||||||
|
useFull = form.get('search.2.full')
|
||||||
|
conceptType = form.get('search.3.type', 'loops:concept:*')
|
||||||
|
#conceptTitle = form.get('search.3.text')
|
||||||
|
#if conceptTitle is not None:
|
||||||
|
# conceptTitle = util.toUnicode(conceptTitle, encoding='ISO8859-15')
|
||||||
|
conceptUid = form.get('search.3.text')
|
||||||
|
result = FullQuery(self).query(text=text, type=type,
|
||||||
|
useTitle=useTitle, useFull=useFull,
|
||||||
|
#conceptTitle=conceptTitle,
|
||||||
|
conceptUid=conceptUid,
|
||||||
|
conceptType=conceptType)
|
||||||
|
rowNum = 4
|
||||||
|
while rowNum < 10:
|
||||||
|
addCriteria = form.get('search.%i.text_selected' % rowNum)
|
||||||
|
rowNum += 1
|
||||||
|
if not addCriteria:
|
||||||
|
break
|
||||||
|
if addCriteria == 'none':
|
||||||
|
continue
|
||||||
|
addSelection = FullQuery(self).query(text=text, type=type,
|
||||||
|
useTitle=useTitle, useFull=useFull,
|
||||||
|
conceptUid=addCriteria)
|
||||||
|
if result:
|
||||||
|
result = [r for r in result if r in addSelection]
|
||||||
|
else:
|
||||||
|
result = addSelection
|
||||||
|
result = [r for r in result if self.checkStates(r)]
|
||||||
|
fv = FilterView(self.context, self.request)
|
||||||
|
result = fv.apply(result)
|
||||||
|
result = sorted(result, key=lambda x: x.title.lower())
|
||||||
|
return self.viewIterator(result)
|
||||||
|
|
||||||
|
def checkStates(self, obj):
|
||||||
|
if not IResource.providedBy(obj):
|
||||||
|
return True
|
||||||
|
for std, states in self.selectedStates.items():
|
||||||
|
if std.startswith('state.resource.'):
|
||||||
|
std = std[len('state.resource.'):]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
stf = component.queryAdapter(obj, IStateful, name=std)
|
||||||
|
if stf is None:
|
||||||
|
continue
|
||||||
|
for state in states:
|
||||||
|
if stf.state == state:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
#class SearchResults(BaseView):
|
||||||
|
class SearchResults(NodeView):
|
||||||
|
""" Provides results as inner HTML """
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def search_macros(self):
|
||||||
|
return self.controller.getTemplateMacros('search', search_template)
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def macro(self):
|
||||||
|
return self.search_macros['search_results']
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return innerHtml(self)
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def results(self):
|
||||||
|
form = self.request.form
|
||||||
|
type = form.get('search.1.text', 'loops:*')
|
||||||
|
text = form.get('search.2.text')
|
||||||
|
if text is not None:
|
||||||
|
text = util.toUnicode(text, encoding='ISO8859-15') # IE hack!!!
|
||||||
|
useTitle = form.get('search.2.title')
|
||||||
|
useFull = form.get('search.2.full')
|
||||||
|
conceptType = form.get('search.3.type', 'loops:concept:*')
|
||||||
|
#conceptTitle = form.get('search.3.text')
|
||||||
|
#if conceptTitle is not None:
|
||||||
|
# conceptTitle = util.toUnicode(conceptTitle, encoding='ISO8859-15')
|
||||||
|
conceptUid = form.get('search.3.text')
|
||||||
|
result = FullQuery(self).query(text=text, type=type,
|
||||||
|
useTitle=useTitle, useFull=useFull,
|
||||||
|
#conceptTitle=conceptTitle,
|
||||||
|
conceptUid=conceptUid,
|
||||||
|
conceptType=conceptType)
|
||||||
|
rowNum = 4
|
||||||
|
while rowNum < 10:
|
||||||
|
addCriteria = form.get('search.%i.text_selected' % rowNum)
|
||||||
|
rowNum += 1
|
||||||
|
if not addCriteria:
|
||||||
|
break
|
||||||
|
if addCriteria == 'none':
|
||||||
|
continue
|
||||||
|
addSelection = FullQuery(self).query(text=text, type=type,
|
||||||
|
useTitle=useTitle, useFull=useFull,
|
||||||
|
conceptUid=addCriteria)
|
||||||
|
if result:
|
||||||
|
result = [r for r in result if r in addSelection]
|
||||||
|
else:
|
||||||
|
result = addSelection
|
||||||
|
fv = FilterView(self.context, self.request)
|
||||||
|
result = fv.apply(result)
|
||||||
|
result = sorted(result, key=lambda x: x.title.lower())
|
||||||
|
return self.viewIterator(result)
|
||||||
|
|
||||||
|
|
239
expert/search.txt
Executable file
239
expert/search.txt
Executable file
|
@ -0,0 +1,239 @@
|
||||||
|
===================================================================
|
||||||
|
loops.search - Provide search functionality for the loops framework
|
||||||
|
===================================================================
|
||||||
|
|
||||||
|
($Id$)
|
||||||
|
|
||||||
|
|
||||||
|
Let's do some basic set up
|
||||||
|
|
||||||
|
>>> from zope.app.testing.setup import placefulSetUp, placefulTearDown
|
||||||
|
>>> site = placefulSetUp(True)
|
||||||
|
|
||||||
|
>>> from zope import component, interface
|
||||||
|
>>> from zope.interface import implements
|
||||||
|
|
||||||
|
and setup a simple loops site with a concept manager and some concepts
|
||||||
|
(with all the type machinery, what in real life is done via standard
|
||||||
|
ZCML setup):
|
||||||
|
|
||||||
|
>>> from loops.concept import Concept
|
||||||
|
>>> from loops.type import ConceptType, TypeConcept
|
||||||
|
>>> from loops.interfaces import ITypeConcept
|
||||||
|
>>> from loops.base import Loops
|
||||||
|
>>> from loops.expert.testsetup import TestSite
|
||||||
|
>>> t = TestSite(site)
|
||||||
|
>>> concepts, resources, views = t.setup()
|
||||||
|
|
||||||
|
>>> loopsRoot = site['loops']
|
||||||
|
>>> query = concepts['query']
|
||||||
|
>>> topic = concepts['topic']
|
||||||
|
|
||||||
|
In addition we create a concept that holds the search page and a node
|
||||||
|
(page) that links to this concept:
|
||||||
|
|
||||||
|
>>> search = concepts['search'] = Concept(u'Search')
|
||||||
|
>>> search.conceptType = query
|
||||||
|
|
||||||
|
>>> from loops.view import Node
|
||||||
|
>>> page = views['page'] = Node('Search Page')
|
||||||
|
>>> page.target = search
|
||||||
|
|
||||||
|
Search views
|
||||||
|
------------
|
||||||
|
|
||||||
|
Now we are ready to create a search view object:
|
||||||
|
|
||||||
|
>>> from zope.publisher.browser import TestRequest
|
||||||
|
>>> from loops.expert.browser.search import Search
|
||||||
|
>>> searchView = Search(search, TestRequest())
|
||||||
|
|
||||||
|
The search view provides values for identifying the search form itself
|
||||||
|
and the parameter rows; the rowNum is auto-incremented, so it should be
|
||||||
|
accessed exactly once per row:
|
||||||
|
|
||||||
|
>>> searchView.rowNum
|
||||||
|
1
|
||||||
|
>>> searchView.rowNum
|
||||||
|
2
|
||||||
|
|
||||||
|
The search view provides vocabularies for types that allow the selection
|
||||||
|
of types to search for; this needs an ITypeManager adapter registered via
|
||||||
|
zcml in real life:
|
||||||
|
|
||||||
|
>>> from loops.type import LoopsTypeManager
|
||||||
|
>>> component.provideAdapter(LoopsTypeManager)
|
||||||
|
|
||||||
|
>>> t = searchView.typesForSearch()
|
||||||
|
>>> len(t)
|
||||||
|
14
|
||||||
|
>>> t.getTermByToken('loops:resource:*').title
|
||||||
|
'Any Resource'
|
||||||
|
|
||||||
|
>>> t = searchView.conceptTypesForSearch()
|
||||||
|
>>> len(t)
|
||||||
|
11
|
||||||
|
>>> t.getTermByToken('loops:concept:*').title
|
||||||
|
'Any Concept'
|
||||||
|
|
||||||
|
To execute the search in the context of a node we have to set up a node
|
||||||
|
view for our page. The submitReplacing method returns a JavaScript call
|
||||||
|
that will replace the results part on the search page; as this registers
|
||||||
|
the dojo library with the view's controller we also have to supply
|
||||||
|
a controller attribute for the search view.
|
||||||
|
|
||||||
|
>>> from loops.browser.node import NodeView
|
||||||
|
>>> request = TestRequest()
|
||||||
|
>>> pageView = NodeView(page, request)
|
||||||
|
|
||||||
|
>>> from cybertools.browser.liquid.controller import Controller
|
||||||
|
>>> searchView.controller = Controller(searchView, request)
|
||||||
|
|
||||||
|
>>> searchView.submitReplacing('1.results', '1.search.form', pageView)
|
||||||
|
'submitReplacing("1.results", "1.search.form",
|
||||||
|
"http://127.0.0.1/loops/views/page/.target80/@@searchresults.html");...'
|
||||||
|
|
||||||
|
Basic (text/title) search
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
The searchresults.html view, i.e. the SearchResults view class provides the
|
||||||
|
result set of the search via its `results` property.
|
||||||
|
|
||||||
|
Before accessing the `results` property we have to prepare a
|
||||||
|
resource we can search for and index it in the catalog.
|
||||||
|
|
||||||
|
>>> from loops.resource import Resource
|
||||||
|
>>> rplone = resources['plone'] = Resource()
|
||||||
|
|
||||||
|
>>> from zope.app.catalog.interfaces import ICatalog
|
||||||
|
>>> from loops import util
|
||||||
|
>>> catalog = component.getUtility(ICatalog)
|
||||||
|
>>> catalog.index_doc(int(util.getUidForObject(rplone)), rplone)
|
||||||
|
|
||||||
|
>>> from loops.expert.browser.search import SearchResults
|
||||||
|
>>> form = {'search.2.title': True, 'search.2.text': u'plone'}
|
||||||
|
>>> request = TestRequest(form=form)
|
||||||
|
>>> resultsView = SearchResults(page, request)
|
||||||
|
|
||||||
|
>>> results = list(resultsView.results)
|
||||||
|
>>> len(results)
|
||||||
|
1
|
||||||
|
>>> results[0].context == rplone
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> form = {'search.2.title': True, 'search.2.text': u'foo'}
|
||||||
|
>>> request = TestRequest(form=form)
|
||||||
|
>>> resultsView = SearchResults(page, request)
|
||||||
|
>>> len(list(resultsView.results))
|
||||||
|
0
|
||||||
|
|
||||||
|
Search via related concepts
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
We first have to prepare some test concepts (topics); we also assign our test
|
||||||
|
resource (rplone) from above to one of the topics:
|
||||||
|
|
||||||
|
>>> czope = concepts['zope'] = Concept(u'Zope')
|
||||||
|
>>> czope2 = concepts['zope2'] = Concept(u'Zope 2')
|
||||||
|
>>> czope3 = concepts['zope3'] = Concept(u'Zope 3')
|
||||||
|
>>> cplone = concepts['plone'] = Concept(u'Plone')
|
||||||
|
>>> for c in (czope, czope2, czope3, cplone):
|
||||||
|
... c.conceptType = topic
|
||||||
|
... catalog.index_doc(int(util.getUidForObject(c)), c)
|
||||||
|
>>> czope.assignChild(czope2)
|
||||||
|
>>> czope.assignChild(czope3)
|
||||||
|
>>> czope2.assignChild(cplone)
|
||||||
|
>>> rplone.assignConcept(cplone)
|
||||||
|
|
||||||
|
Now we can fill our search form and execute the query; note that all concepts
|
||||||
|
found are listed, plus all their children and all resources associated
|
||||||
|
with them:
|
||||||
|
|
||||||
|
>>> uid = util.getUidForObject(concepts['zope'])
|
||||||
|
>>> form = {'search.3.type': 'loops:concept:topic', 'search.3.text': uid}
|
||||||
|
>>> request = TestRequest(form=form)
|
||||||
|
>>> resultsView = SearchResults(page, request)
|
||||||
|
>>> results = list(resultsView.results)
|
||||||
|
>>> len(results)
|
||||||
|
5
|
||||||
|
>>> results[0].context.__name__
|
||||||
|
u'plone'
|
||||||
|
|
||||||
|
>>> uid = util.getUidForObject(concepts['zope3'])
|
||||||
|
>>> form = {'search.3.type': 'loops:concept:topic', 'search.3.text': uid}
|
||||||
|
>>> request = TestRequest(form=form)
|
||||||
|
>>> resultsView = SearchResults(page, request)
|
||||||
|
>>> results = list(resultsView.results)
|
||||||
|
>>> len(results)
|
||||||
|
1
|
||||||
|
>>> results[0].context.__name__
|
||||||
|
u'zope3'
|
||||||
|
|
||||||
|
To support easy entry of concepts to search for we can preselect the available
|
||||||
|
concepts (optionally restricted to a certain type) by entering text parts
|
||||||
|
of the concepts' titles:
|
||||||
|
|
||||||
|
>>> form = {'searchType': 'loops:concept:topic', 'name': u'zope'}
|
||||||
|
>>> request = TestRequest(form=form)
|
||||||
|
>>> view = Search(page, request)
|
||||||
|
>>> view.listConcepts()
|
||||||
|
u"{identifier: 'id', items: [{label: 'Zope (Topic)', name: 'Zope', id: '85'}, {label: 'Zope 2 (Topic)', name: 'Zope 2', id: '87'}, {label: 'Zope 3 (Topic)', name: 'Zope 3', id: '89'}]}"
|
||||||
|
|
||||||
|
Preset Concept Types on Search Forms
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
Often we want to include certain types in our search. We can instruct
|
||||||
|
the search form to include lines for these types by giving these types
|
||||||
|
a certain qualifier, via the option attribute of the type interface.
|
||||||
|
|
||||||
|
Let's start with a new type, the customer type.
|
||||||
|
|
||||||
|
>>> customer = concepts['customer']
|
||||||
|
>>> custType = ITypeConcept(customer)
|
||||||
|
>>> custType.options
|
||||||
|
[]
|
||||||
|
|
||||||
|
>>> cust1 = concepts['cust1']
|
||||||
|
>>> cust2 = concepts['cust2']
|
||||||
|
>>> for c in (cust1, cust2):
|
||||||
|
... c.conceptType = customer
|
||||||
|
... catalog.index_doc(int(util.getUidForObject(c)), c)
|
||||||
|
|
||||||
|
>>> from cybertools.typology.interfaces import IType
|
||||||
|
>>> IType(cust1).qualifiers
|
||||||
|
('concept',)
|
||||||
|
|
||||||
|
>>> searchView = Search(search, TestRequest())
|
||||||
|
>>> list(searchView.presetSearchTypes)
|
||||||
|
[]
|
||||||
|
|
||||||
|
We can now add a 'search' qualifier to the customer type's options
|
||||||
|
and thus include the customer type in the preset search types.
|
||||||
|
|
||||||
|
>>> custType.options = ('qualifier:search',)
|
||||||
|
>>> IType(cust1).qualifiers
|
||||||
|
('concept', 'search')
|
||||||
|
>>> searchView = Search(search, TestRequest())
|
||||||
|
>>> list(searchView.presetSearchTypes)
|
||||||
|
[{'token': 'loops:concept:customer', 'title': u'Customer'}]
|
||||||
|
|
||||||
|
>>> searchView.conceptsForType('loops:concept:customer')
|
||||||
|
[{'token': 'none', 'title': u'not selected'},
|
||||||
|
{'token': '58', 'title': u'Customer 1'},
|
||||||
|
{'token': '60', 'title': u'Customer 2'},
|
||||||
|
{'token': '62', 'title': u'Customer 3'}]
|
||||||
|
|
||||||
|
Let's use this new search option for querying:
|
||||||
|
|
||||||
|
>>> form = {'search.4.text_selected': u'58'}
|
||||||
|
>>> resultsView = SearchResults(page, TestRequest(form=form))
|
||||||
|
>>> results = list(resultsView.results)
|
||||||
|
>>> results[0].title
|
||||||
|
u'Customer 1'
|
||||||
|
|
||||||
|
|
||||||
|
Automatic Filtering
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
TODO - more to come...
|
||||||
|
|
|
@ -24,6 +24,7 @@ def test_suite():
|
||||||
return unittest.TestSuite((
|
return unittest.TestSuite((
|
||||||
unittest.makeSuite(Test),
|
unittest.makeSuite(Test),
|
||||||
DocFileSuite('README.txt', optionflags=flags),
|
DocFileSuite('README.txt', optionflags=flags),
|
||||||
|
DocFileSuite('search.txt', optionflags=flags),
|
||||||
))
|
))
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -31,7 +31,7 @@ from cybertools.stateful.interfaces import IStateful, IStatesDefinition
|
||||||
from loops.browser.common import BaseView
|
from loops.browser.common import BaseView
|
||||||
from loops.browser.concept import ConceptView
|
from loops.browser.concept import ConceptView
|
||||||
from loops.expert.query import And, Or, State, Type, getObjects
|
from loops.expert.query import And, Or, State, Type, getObjects
|
||||||
from loops.search.browser import search_template
|
from loops.expert.browser.search import search_template
|
||||||
from loops.util import _
|
from loops.util import _
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -45,7 +45,7 @@ Search views
|
||||||
Now we are ready to create a search view object:
|
Now we are ready to create a search view object:
|
||||||
|
|
||||||
>>> from zope.publisher.browser import TestRequest
|
>>> from zope.publisher.browser import TestRequest
|
||||||
>>> from loops.search.browser import Search
|
>>> from loops.expert.browser.search import Search
|
||||||
>>> searchView = Search(search, TestRequest())
|
>>> searchView = Search(search, TestRequest())
|
||||||
|
|
||||||
The search view provides values for identifying the search form itself
|
The search view provides values for identifying the search form itself
|
||||||
|
@ -110,7 +110,7 @@ resource we can search for and index it in the catalog.
|
||||||
>>> catalog = component.getUtility(ICatalog)
|
>>> catalog = component.getUtility(ICatalog)
|
||||||
>>> catalog.index_doc(int(util.getUidForObject(rplone)), rplone)
|
>>> catalog.index_doc(int(util.getUidForObject(rplone)), rplone)
|
||||||
|
|
||||||
>>> from loops.search.browser import SearchResults
|
>>> from loops.expert.browser.search import SearchResults
|
||||||
>>> form = {'search.2.title': True, 'search.2.text': u'plone'}
|
>>> form = {'search.2.title': True, 'search.2.text': u'plone'}
|
||||||
>>> request = TestRequest(form=form)
|
>>> request = TestRequest(form=form)
|
||||||
>>> resultsView = SearchResults(page, request)
|
>>> resultsView = SearchResults(page, request)
|
||||||
|
|
|
@ -37,6 +37,7 @@ from cybertools.typology.interfaces import ITypeManager
|
||||||
from loops.browser.common import BaseView
|
from loops.browser.common import BaseView
|
||||||
from loops.browser.node import NodeView
|
from loops.browser.node import NodeView
|
||||||
from loops.common import adapted, AdapterBase
|
from loops.common import adapted, AdapterBase
|
||||||
|
#from loops.expert.browser.search import searchMacrosTemplate as search_template
|
||||||
from loops.expert.concept import ConceptQuery, FullQuery
|
from loops.expert.concept import ConceptQuery, FullQuery
|
||||||
from loops.interfaces import IResource
|
from loops.interfaces import IResource
|
||||||
from loops.organize.personal.browser.filter import FilterView
|
from loops.organize.personal.browser.filter import FilterView
|
||||||
|
@ -44,7 +45,7 @@ from loops import util
|
||||||
from loops.util import _
|
from loops.util import _
|
||||||
|
|
||||||
|
|
||||||
search_template = ViewPageTemplateFile('search.pt')
|
#search_template = ViewPageTemplateFile('search.pt')
|
||||||
|
|
||||||
|
|
||||||
class Search(BaseView):
|
class Search(BaseView):
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
i18n_domain="zope"
|
i18n_domain="zope"
|
||||||
>
|
>
|
||||||
|
|
||||||
<zope:adapter
|
<!--<zope:adapter
|
||||||
name="search"
|
name="search"
|
||||||
for="loops.interfaces.IConcept
|
for="loops.interfaces.IConcept
|
||||||
zope.publisher.interfaces.browser.IBrowserRequest"
|
zope.publisher.interfaces.browser.IBrowserRequest"
|
||||||
|
@ -28,6 +28,6 @@
|
||||||
for="loops.interfaces.ILoopsObject"
|
for="loops.interfaces.ILoopsObject"
|
||||||
class="loops.search.browser.SearchResults"
|
class="loops.search.browser.SearchResults"
|
||||||
permission="zope.View"
|
permission="zope.View"
|
||||||
/>
|
/>-->
|
||||||
|
|
||||||
</configure>
|
</configure>
|
||||||
|
|
|
@ -43,6 +43,9 @@
|
||||||
</form>
|
</form>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
<tal:results condition="request/search.submitted|nothing">
|
||||||
|
<metal:results use-macro="item/search_macros/results" />
|
||||||
|
</tal:results>
|
||||||
|
|
||||||
<div metal:define-macro="search_results" id="1.search.results"
|
<div metal:define-macro="search_results" id="1.search.results"
|
||||||
tal:attributes="id resultsId | request/search.resultsId"
|
tal:attributes="id resultsId | request/search.resultsId"
|
||||||
|
|
Loading…
Add table
Reference in a new issue