Added adapters to advanced search forms to be able to use Elasticsearch in back-office
authorThierry Florac <tflorac@ulthar.net>
Sun, 19 Jul 2020 02:11:57 +0200
changeset 164 e48a3850c17a
parent 163 838fd9ca54fb
child 165 1945b374d388
Added adapters to advanced search forms to be able to use Elasticsearch in back-office
src/pyams_content_es/locales/fr/LC_MESSAGES/pyams_content_es.mo
src/pyams_content_es/locales/fr/LC_MESSAGES/pyams_content_es.po
src/pyams_content_es/locales/pyams_content_es.pot
src/pyams_content_es/root/__init__.py
src/pyams_content_es/root/zmi/__init__.py
src/pyams_content_es/root/zmi/search.py
src/pyams_content_es/root/zmi/templates/advanced-search.pt
src/pyams_content_es/shared/zmi/__init__.py
src/pyams_content_es/shared/zmi/search.py
src/pyams_content_es/shared/zmi/templates/advanced-search.pt
Binary file src/pyams_content_es/locales/fr/LC_MESSAGES/pyams_content_es.mo has changed
--- a/src/pyams_content_es/locales/fr/LC_MESSAGES/pyams_content_es.po	Sat Jul 18 19:37:58 2020 +0200
+++ b/src/pyams_content_es/locales/fr/LC_MESSAGES/pyams_content_es.po	Sun Jul 19 02:11:57 2020 +0200
@@ -5,7 +5,7 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE 1.0\n"
-"POT-Creation-Date: 2019-03-01 13:28+0100\n"
+"POT-Creation-Date: 2020-07-18 20:14+0200\n"
 "PO-Revision-Date: 2016-04-21 18:26+0200\n"
 "Last-Translator: Thierry Florac <tflorac@ulthar.net>\n"
 "Language-Team: French\n"
@@ -24,53 +24,53 @@
 msgid "Name of ZODB connection defining indexer connection"
 msgstr "Nom de la ZODB définissant les paramètres de connexion"
 
-#: src/pyams_content_es/zmi/db.py:53
+#: src/pyams_content_es/zmi/db.py:52
 msgid "Check index contents..."
 msgstr "Vérifier le contenu de l'index"
 
-#: src/pyams_content_es/zmi/db.py:79
+#: src/pyams_content_es/zmi/db.py:78
 #: src/pyams_content_es/zmi/templates/index-ok.pt:9
 msgid "Check index contents"
 msgstr "Vérifier le contenu de l'index"
 
-#: src/pyams_content_es/zmi/db.py:63 src/pyams_content_es/zmi/__init__.py:82
+#: src/pyams_content_es/zmi/db.py:62 src/pyams_content_es/zmi/__init__.py:81
 #: src/pyams_content_es/zmi/templates/index-ok.pt:19
 #: src/pyams_content_es/zmi/templates/index-updater.pt:31
 msgid "Close"
 msgstr "Fermer"
 
-#: src/pyams_content_es/zmi/db.py:64
+#: src/pyams_content_es/zmi/db.py:63
 #: src/pyams_content_es/zmi/templates/index-ok.pt:24
 msgid "Check index"
 msgstr "Vérifier l'index"
 
-#: src/pyams_content_es/zmi/db.py:185
+#: src/pyams_content_es/zmi/db.py:184
 msgid "Requested contents have been re-indexed!"
 msgstr "Les contenus indiqués ont été ré-indexés !"
 
-#: src/pyams_content_es/zmi/db.py:130
+#: src/pyams_content_es/zmi/db.py:129
 #, python-format
 msgid "Loading index data ({})..."
 msgstr "Chargement de l'index ({})..."
 
-#: src/pyams_content_es/zmi/db.py:139
+#: src/pyams_content_es/zmi/db.py:138
 #, python-format
 msgid "Loading database contents ({})..."
 msgstr "Chargement des contenus ({})..."
 
-#: src/pyams_content_es/zmi/__init__.py:53
+#: src/pyams_content_es/zmi/__init__.py:52
 msgid "Update content indexer properties"
 msgstr "Propriétés de l'indexeur de contenus"
 
-#: src/pyams_content_es/zmi/__init__.py:72
+#: src/pyams_content_es/zmi/__init__.py:71
 msgid "Test process connection..."
 msgstr "Tester la connexion"
 
-#: src/pyams_content_es/zmi/__init__.py:97
+#: src/pyams_content_es/zmi/__init__.py:96
 msgid "Test content indexer process connection"
 msgstr "Test de la connexion au processus d'indexation"
 
-#: src/pyams_content_es/zmi/__init__.py:83
+#: src/pyams_content_es/zmi/__init__.py:82
 msgid "Test connection"
 msgstr "Tester la connexion"
 
@@ -85,3 +85,64 @@
 #: src/pyams_content_es/zmi/templates/index-updater.pt:35
 msgid "Update index"
 msgstr "Mettre l'index à jour"
+
+#: src/pyams_content_es/root/zmi/search.py:47
+#: src/pyams_content_es/shared/zmi/search.py:50
+msgid "Search query"
+msgstr "Texte recherché"
+
+#: src/pyams_content_es/root/zmi/search.py:48
+#: src/pyams_content_es/shared/zmi/search.py:51
+msgid ""
+"This query will applies only on title, short name, header and description; "
+"if fulltext search option is selected, this will activate search on all "
+"paragraphs, including attachments"
+msgstr ""
+"La requête s'applique par défaut uniquement au titre, au titre court, au chapô "
+"et à la description ; si l'option \"full-text\" est sélectionnée, cela activera "
+"également la recherche dans le corps des paragraphes, y compris les pièces jointes"
+
+#: src/pyams_content_es/root/zmi/search.py:54
+#: src/pyams_content_es/shared/zmi/search.py:57
+msgid "Fulltext search"
+msgstr "Recherche full-text"
+
+#: src/pyams_content_es/root/zmi/search.py:55
+#: src/pyams_content_es/shared/zmi/search.py:58
+msgid "Search terms in fulltext mode instead of only on contents titles"
+msgstr "Lancer la recherche en mode full-text et non uniquement sur les titres et les chapôs"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:137
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:160
+msgid "Created between"
+msgstr "Créé entre le"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:149
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:175
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:172
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:198
+msgid "and"
+msgstr "et le"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:163
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:186
+msgid "Modified between"
+msgstr "Modifié entre le"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:188
+msgid "Tags"
+msgstr "Tags"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:195
+msgid "Collections"
+msgstr "Collections"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:211
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:230
+msgid "Title"
+msgstr "Titre"
+
+#: src/pyams_content_es/root/zmi/templates/advanced-search.pt:225
+#: src/pyams_content_es/shared/zmi/templates/advanced-search.pt:244
+msgid "Tab label"
+msgstr "Libellé"
--- a/src/pyams_content_es/locales/pyams_content_es.pot	Sat Jul 18 19:37:58 2020 +0200
+++ b/src/pyams_content_es/locales/pyams_content_es.pot	Sun Jul 19 02:11:57 2020 +0200
@@ -1,12 +1,12 @@
 #
 # SOME DESCRIPTIVE TITLE
 # This file is distributed under the same license as the PACKAGE package.
-# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2020.
 #, fuzzy
 msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE 1.0\n"
-"POT-Creation-Date: 2019-03-01 13:28+0100\n"
+"POT-Creation-Date: 2020-07-18 20:14+0200\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -24,54 +24,54 @@
 msgid "Name of ZODB connection defining indexer connection"
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/db.py:53
+#: ./src/pyams_content_es/zmi/db.py:52
 msgid "Check index contents..."
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/db.py:79
+#: ./src/pyams_content_es/zmi/db.py:78
 #: ./src/pyams_content_es/zmi/templates/index-ok.pt:9
 msgid "Check index contents"
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/db.py:63
-#: ./src/pyams_content_es/zmi/__init__.py:82
+#: ./src/pyams_content_es/zmi/db.py:62
+#: ./src/pyams_content_es/zmi/__init__.py:81
 #: ./src/pyams_content_es/zmi/templates/index-ok.pt:19
 #: ./src/pyams_content_es/zmi/templates/index-updater.pt:31
 msgid "Close"
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/db.py:64
+#: ./src/pyams_content_es/zmi/db.py:63
 #: ./src/pyams_content_es/zmi/templates/index-ok.pt:24
 msgid "Check index"
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/db.py:185
+#: ./src/pyams_content_es/zmi/db.py:184
 msgid "Requested contents have been re-indexed!"
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/db.py:130
+#: ./src/pyams_content_es/zmi/db.py:129
 #, python-format
 msgid "Loading index data ({})..."
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/db.py:139
+#: ./src/pyams_content_es/zmi/db.py:138
 #, python-format
 msgid "Loading database contents ({})..."
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/__init__.py:53
+#: ./src/pyams_content_es/zmi/__init__.py:52
 msgid "Update content indexer properties"
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/__init__.py:72
+#: ./src/pyams_content_es/zmi/__init__.py:71
 msgid "Test process connection..."
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/__init__.py:97
+#: ./src/pyams_content_es/zmi/__init__.py:96
 msgid "Test content indexer process connection"
 msgstr ""
 
-#: ./src/pyams_content_es/zmi/__init__.py:83
+#: ./src/pyams_content_es/zmi/__init__.py:82
 msgid "Test connection"
 msgstr ""
 
@@ -86,3 +86,61 @@
 #: ./src/pyams_content_es/zmi/templates/index-updater.pt:35
 msgid "Update index"
 msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/search.py:47
+#: ./src/pyams_content_es/shared/zmi/search.py:50
+msgid "Search query"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/search.py:48
+#: ./src/pyams_content_es/shared/zmi/search.py:51
+msgid ""
+"This query will applies only on title, short name, header and description; if"
+" fulltext search option is selected, this will activate search on all "
+"paragraphs, including attachments"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/search.py:54
+#: ./src/pyams_content_es/shared/zmi/search.py:57
+msgid "Fulltext search"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/search.py:55
+#: ./src/pyams_content_es/shared/zmi/search.py:58
+msgid "Search terms in fulltext mode instead of only on contents titles"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:137
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:160
+msgid "Created between"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:149
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:175
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:172
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:198
+msgid "and"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:163
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:186
+msgid "Modified between"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:188
+msgid "Tags"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:195
+msgid "Collections"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:211
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:230
+msgid "Title"
+msgstr ""
+
+#: ./src/pyams_content_es/root/zmi/templates/advanced-search.pt:225
+#: ./src/pyams_content_es/shared/zmi/templates/advanced-search.pt:244
+msgid "Tab label"
+msgstr ""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/root/__init__.py	Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2015-2020 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""PyAMS_*** module
+
+"""
+
+__docformat__ = 'restructuredtext'
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/root/zmi/__init__.py	Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2015-2020 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""PyAMS_*** module
+
+"""
+
+__docformat__ = 'restructuredtext'
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/root/zmi/search.py	Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,154 @@
+#
+# Copyright (c) 2015-2020 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""PyAMS_content_es.root.zmi.search module
+
+"""
+from elasticsearch_dsl import Q, Search
+from pyramid_es import get_client
+from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget
+from zope.intid import IIntIds
+from zope.schema import Bool, TextLine
+
+from pyams_catalog.query import CatalogResultSet
+from pyams_content.root import ISiteRoot
+from pyams_content.root.zmi.search import ISiteRootAdvancedSearchFields, \
+    SiteRootAdvancedSearchForm, SiteRootAdvancedSearchResultsView, SiteRootAdvancedSearchView
+from pyams_content.shared.common import CONTENT_TYPES
+from pyams_form.interfaces.form import ISearchFormFactory
+from pyams_i18n.interfaces import INegotiator
+from pyams_sequence.interfaces import ISequentialIntIds
+from pyams_sequence.reference import get_last_version
+from pyams_skin.interfaces import IContentSearch
+from pyams_skin.layer import IPyAMSLayer
+from pyams_template.template import template_config
+from pyams_utils.adapter import ContextRequestViewAdapter, adapter_config
+from pyams_utils.list import unique
+from pyams_utils.registry import get_utility
+
+
+__docformat__ = 'restructuredtext'
+
+from pyams_content_es import _
+
+
+class IEsSiteRootAdvancedSearchFields(ISiteRootAdvancedSearchFields):
+    """Elasticsearch based site root advanced search fields"""
+
+    query = TextLine(title=_("Search query"),
+                     description=_("This query will applies only on title, short name, header "
+                                   "and description; if fulltext search option is selected, this "
+                                   "will activate search on all paragraphs, including "
+                                   "attachments"),
+                     required=False)
+
+    fulltext = Bool(title=_("Fulltext search"),
+                    description=_("Search terms in fulltext mode instead of only on contents "
+                                  "titles"),
+                    required=False,
+                    default=False)
+
+
+@template_config(template='templates/advanced-search.pt', layer=IPyAMSLayer)
+class EsSiteRootAdvancedSearchForm(SiteRootAdvancedSearchForm):
+    """Elasticsearch site root advanced search form"""
+
+    fields_interface = IEsSiteRootAdvancedSearchFields
+
+    @property
+    def fields(self):
+        fields = super(EsSiteRootAdvancedSearchForm, self).fields
+        fields['fulltext'].widgetFactory = SingleCheckBoxFieldWidget
+        return fields
+
+
+@adapter_config(context=(ISiteRoot, IPyAMSLayer, SiteRootAdvancedSearchView),
+                provides=ISearchFormFactory)
+@adapter_config(context=(ISiteRoot, IPyAMSLayer, SiteRootAdvancedSearchResultsView),
+                provides=ISearchFormFactory)
+def site_root_advanced_search_form_factory(context, request, view):
+    """Elasticsearch site root advanced search form factory"""
+    return EsSiteRootAdvancedSearchForm(context, request)
+
+
+@adapter_config(context=(ISiteRoot, IPyAMSLayer, EsSiteRootAdvancedSearchForm),
+                provides=IContentSearch)
+class EsSharedToolAdvancedSearchFormSearchAdapter(ContextRequestViewAdapter):
+    """Elasticsearch site root advanced search form adapter"""
+
+    def get_search_results(self, data):
+        intids = get_utility(IIntIds)
+        params = Q('term', parent_ids=intids.register(self.context))
+        if data.get('content_type'):
+            params &= Q('terms', content_type=data['content_type'])
+        else:
+            params &= Q('terms', content_type=list(CONTENT_TYPES.keys()))
+        # check query settings
+        query = data.get('query')
+        if query:
+            sequence = get_utility(ISequentialIntIds)
+            if query.startswith('+'):
+                params &= Q('term', reference_id=sequence.get_full_oid(query))
+            else:
+                fulltext = data.get('fulltext', False)
+                if fulltext:
+                    params &= Q('simple_query_string', query=query)
+                else:
+                    negotiator = get_utility(INegotiator)
+                    query_params = Q('bool')
+                    for lang in {self.request.registry.settings.get('pyramid.default_locale_name',
+                                                                    'en'),
+                                 self.request.locale_name,
+                                 negotiator.server_language} | negotiator.offered_languages:
+                        query_params |= Q('match', **{'title.{}'.format(lang): query})
+                        query_params |= Q('match', **{'short_name.{}'.format(lang): query})
+                        query_params |= Q('match', **{'header.{}'.format(lang): query})
+                        query_params |= Q('match', **{'description.{}'.format(lang): query})
+                    params &= query_params
+        if data.get('owner'):
+            params &= Q('term', owner_id=data['owner'])
+        if data.get('created_after'):
+            params &= Q('range', workflow__created_date={'gte': data['created_after']})
+        if data.get('created_before'):
+            params &= Q('range', workflow__created_date={'lte': data['created_before']})
+        if data.get('modified_after'):
+            params &= Q('range', workflow__modified_date={'gte': data['modified_after']})
+        if data.get('modified_before'):
+            params &= Q('range', workflow__modified_date={'lte': data['modified_before']})
+        if data.get('tags'):
+            tags = [intids.register(term) for term in data['tags']]
+            params &= Q('terms', tags=tags)
+        if data.get('collections'):
+            collections = [intids.register(collection) for collection in data['collections']]
+            params &= Q('terms', collections=collections)
+        # get ES client and create search object
+        client = get_client(self.request)
+        search = Search(using=client.es, index=client.index) \
+            .params(request_timeout=30) \
+            .query(params) \
+            .source(['internal_id'])
+        sort_values = [{
+            'workflow.modified_date': {
+                'unmapped_type': 'date',
+                'order': 'desc'
+            }
+        }]
+        if query:
+            sort_values.insert(0, {
+                '_score': {
+                    'order': 'desc'
+                }
+            })
+        search = search.sort(*sort_values)[:999]
+        # extract results
+        return unique(map(get_last_version,
+                          CatalogResultSet([result.internal_id for result in search])))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/root/zmi/templates/advanced-search.pt	Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,246 @@
+<div class="ams-widget" i18n:domain="pyams_content_es">
+	<header>
+		<span tal:condition="view.widget_icon_class | nothing"
+			  class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
+		</span>
+		<h2 tal:content="view.legend"></h2>
+		<tal:var content="structure provider:pyams.widget_title" />
+		<tal:var content="structure provider:pyams.toolbar" />
+	</header>
+	<div class="widget-body no-padding">
+		<div tal:define="prefix provider:form_prefix"
+			 tal:replace="structure prefix">Form prefix</div>
+		<tal:var content="structure provider:content_help" />
+		<form method="post"
+			  data-async
+			  tal:attributes="id view.id;
+							  name view.name;
+							  action view.get_form_action();
+							  method view.method;
+							  enctype view.enctype;
+							  acceptCharset view.acceptCharset;
+							  accept view.accept;
+							  autocomplete view.autocomplete;
+							  class view.css_class;
+							  data-ams-data tales:object_data(view);
+							  data-ams-form-handler view.get_ajax_handler() | nothing;
+							  data-ams-form-options view.get_form_options() | nothing;
+							  data-ams-form-submit-target view.form_target | nothing;
+							  data-ams-form-download-target view.download_target | nothing;
+							  data-ams-warn-on-change view.warn_on_change;">
+			<div class="modal-viewport">
+				<fieldset>
+					<div class="widgets-prefix"
+						 tal:define="prefix provider:widgets_prefix"
+						 tal:condition="prefix"
+						 tal:content="structure prefix">Widgets prefix</div>
+					<tal:loop repeat="group view.groups">
+						<fieldset tal:define="legend group.legend"
+								  tal:omit-tag="not:legend"
+								  tal:attributes="class 'bordered' if group.bordered else None">
+							<tal:if condition="group.checkbox_switch">
+								<legend data-ams-checker-value="selected"
+										tal:condition="legend"
+										tal:attributes="class group.css_class;
+														data-ams-checker-fieldname '{0}:list'.format(group.checkbox_widget.name);
+														data-ams-checker-readonly 'readonly' if group.checkbox_widget.mode == 'display' else None;
+														data-ams-checker-marker '{0}-empty-marker'.format(group.checkbox_widget.name);
+														data-ams-checker-state group.checker_state;">
+									<label tal:content="legend">Legend</label>
+								</legend>
+							</tal:if>
+							<tal:if condition="not:group.checkbox_switch">
+								<legend tal:condition="legend"
+										tal:content="legend"
+										tal:attributes="class group.css_class;
+														data-ams-switcher-state group.switcher_state;">Legend</legend>
+							</tal:if>
+							<tal:var define="help group.help" condition="help">
+								<div class=""
+									 tal:define="html import:pyams_utils.text.text_to_html;
+												 i18n_help html(request.localizer.translate(help));"
+									 tal:content="structure i18n_help"></div>
+							</tal:var>
+							<div class="form-group" tal:define="widget view.widgets['query']">
+								<label class="control-label col-md-3">
+									<span>
+										<tal:var content="widget.label" />
+										<i class="fa fa-question-circle hint" title="Input hint"
+										   tal:define="description widget.field.description"
+										   tal:condition="description"
+										   tal:attributes="title description;
+														   data-ams-hint-html '<' in description;"></i>
+									</span>
+								</label>
+								<div class="col-md-9">
+									<div class="input"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+									<div class="input padding-10"
+										 tal:define="fulltext view.widgets['fulltext']"
+										 tal:attributes="class fulltext.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(fulltext.field.getName())">
+										<input tal:replace="structure fulltext.render()" />
+									</div>
+								</div>
+							</div>
+							<div class="form-group">
+								<tal:var define="widget view.widgets['owner']">
+									<label class="control-label col-md-3">
+										<span>
+											<tal:var content="widget.label" />
+											<i class="fa fa-question-circle hint" title="Input hint"
+											   tal:define="description widget.field.description"
+											   tal:condition="description"
+											   tal:attributes="title description;
+															   data-ams-hint-html '<' in description;"></i>
+										</span>
+									</label>
+									<div class="col-md-4">
+										<div class="input"
+											 tal:attributes="class widget.widget_css_class | default;
+															 data-ams-data tales:object_data(widget);
+															 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+											<input tal:replace="structure widget.render()" />
+										</div>
+									</div>
+								</tal:var>
+							</div>
+							<div class="form-group">
+								<tal:var define="widget view.widgets['content_type']">
+									<label class="control-label col-md-3">
+										<span>
+											<tal:var content="widget.label" />
+											<i class="fa fa-question-circle hint" title="Input hint"
+											   tal:define="description widget.field.description"
+											   tal:condition="description"
+											   tal:attributes="title description;
+															   data-ams-hint-html '<' in description;"></i>
+										</span>
+									</label>
+									<div class="col-md-4">
+										<div class="input"
+											 tal:attributes="class widget.widget_css_class | default;
+															 data-ams-data tales:object_data(widget);
+															 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+											<input tal:replace="structure widget.render()" />
+										</div>
+									</div>
+								</tal:var>
+							</div>
+							<div class="form-group">
+								<label class="control-label col-md-3">
+									<span i18n:translate="">Created between</span>
+								</label>
+								<div class="col-md-4">
+									<div class="input"
+										 tal:define="widget view.widgets['created_after']"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+								</div>
+								<div class="control-label col-md-1 text-align-center">
+									<i18n:var translate=""> and </i18n:var>
+								</div>
+								<div class="col-md-4">
+									<div class="input"
+										 tal:define="widget view.widgets['created_before']"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+								</div>
+							</div>
+							<div class="form-group">
+								<label class="control-label col-md-3">
+									<span i18n:translate="">Modified between</span>
+								</label>
+								<div class="col-md-4">
+									<div class="input"
+										 tal:define="widget view.widgets['modified_after']"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+								</div>
+								<div class="control-label col-md-1 text-align-center">
+									<i18n:var translate=""> and </i18n:var>
+								</div>
+								<div class="col-md-4">
+									<div class="input"
+										 tal:define="widget view.widgets['modified_before']"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+								</div>
+							</div>
+							<fieldset tal:condition="'tags' in view.widgets">
+								<legend class="switcher" i18n:translate="">Tags</legend>
+								<div class="input"
+									 tal:define="widget view.widgets['tags']">
+									<input tal:replace="structure widget.render()" />
+								</div>
+							</fieldset>
+							<fieldset tal:condition="'collections' in view.widgets">
+								<legend class="switcher" i18n:translate="">Collections</legend>
+								<div class="input"
+									 tal:define="widget view.widgets['collections']">
+									<input tal:replace="structure widget.render()" />
+								</div>
+							</fieldset>
+						</fieldset>
+					</tal:loop>
+					<div class="widgets-suffix"
+						 tal:define="suffix provider:widgets_suffix"
+						 tal:condition="suffix"
+						 tal:content="structure suffix">Widgets suffix</div>
+					<div class="subforms"
+						 tal:condition="view.subforms">
+						<fieldset tal:define="title view.subforms_legend"
+								  tal:omit-tag="not:title">
+							<legend tal:condition="title" tal:content="title" i18n:translate="">Title</legend>
+							<tal:loop repeat="subform view.subforms">
+								<tal:var replace="structure subform.render()" />
+							</tal:loop>
+						</fieldset>
+					</div>
+					<div class="tabforms"
+						 tal:condition="view.tabforms">
+						<ul class="nav nav-tabs">
+							<li tal:repeat="tabform view.tabforms"
+								tal:attributes="class 'small {active} {errors}'.format(active='active' if repeat['tabform'].start() else '',
+																					   errors='state-error' if tabform.widgets.errors else '')">
+								<a data-toggle="tab"
+								   tal:attributes="href string:#${tabform.id}"
+								   tal:content="tabform.tab_label" i18n:translate="">Tab label</a>
+							</li>
+						</ul>
+						<div class="tab-content">
+							<div class="tab-pane fade in"
+								 tal:repeat="tabform view.tabforms"
+								 tal:attributes="id tabform.id;
+												 class 'tab-pane {active} fade in'.format(active='active' if repeat['tabform'].start() else '');"
+								 tal:content="structure tabform.render()"></div>
+						</div>
+					</div>
+				</fieldset>
+			</div>
+			<footer>
+				<button tal:repeat="action view.actions.values()"
+						tal:replace="structure action.render()">Action</button>
+			</footer>
+		</form>
+		<div tal:define="prefix provider:form_suffix"
+			 tal:replace="structure prefix">Form suffix</div>
+	</div>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/shared/zmi/__init__.py	Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,13 @@
+#
+# Copyright (c) 2015-2020 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/shared/zmi/search.py	Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,168 @@
+#
+# Copyright (c) 2015-2020 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""PyAMS_content_es.zmi.search module
+
+"""
+
+from elasticsearch_dsl import Q, Search
+from pyramid_es import get_client
+from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget
+from zope.dublincore.interfaces import IZopeDublinCore
+from zope.intid import IIntIds
+from zope.schema import Bool, TextLine
+
+from pyams_catalog.query import CatalogResultSet
+from pyams_content.shared.common import CONTENT_TYPES, IBaseSharedTool
+from pyams_content.shared.common.zmi.search import ISharedToolAdvancedSearchFields, \
+    SharedToolAdvancedSearchForm, SharedToolAdvancedSearchResultsView, \
+    SharedToolAdvancedSearchView
+from pyams_form.interfaces.form import ISearchFormFactory
+from pyams_i18n.interfaces import INegotiator
+from pyams_sequence.interfaces import ISequentialIntIds
+from pyams_sequence.reference import get_last_version
+from pyams_skin.interfaces import IContentSearch
+from pyams_skin.layer import IPyAMSLayer
+from pyams_template.template import template_config
+from pyams_utils.adapter import ContextRequestViewAdapter, adapter_config
+from pyams_utils.list import unique
+from pyams_utils.registry import get_utility
+from pyams_workflow.interfaces import IWorkflowVersions
+
+
+__docformat__ = 'restructuredtext'
+
+from pyams_content_es import _
+
+
+class IEsSharedToolAdvancedSearchFields(ISharedToolAdvancedSearchFields):
+    """Elasticsearch based shared tool advanced search fields"""
+
+    query = TextLine(title=_("Search query"),
+                     description=_("This query will applies only on title, short name, header "
+                                   "and description; if fulltext search option is selected, this "
+                                   "will activate search on all paragraphs, including "
+                                   "attachments"),
+                     required=False)
+
+    fulltext = Bool(title=_("Fulltext search"),
+                    description=_("Search terms in fulltext mode instead of only on contents "
+                                  "titles"),
+                    required=False,
+                    default=False)
+
+
+@template_config(template='templates/advanced-search.pt', layer=IPyAMSLayer)
+class EsSharedToolAdvancedSearchForm(SharedToolAdvancedSearchForm):
+    """Elasticsearch shared tool advanced search form"""
+
+    fields_interface = IEsSharedToolAdvancedSearchFields
+
+    @property
+    def fields(self):
+        fields = super(EsSharedToolAdvancedSearchForm, self).fields
+        fields['fulltext'].widgetFactory = SingleCheckBoxFieldWidget
+        return fields
+
+
+@adapter_config(context=(IBaseSharedTool, IPyAMSLayer, SharedToolAdvancedSearchView),
+                provides=ISearchFormFactory)
+@adapter_config(context=(IBaseSharedTool, IPyAMSLayer, SharedToolAdvancedSearchResultsView),
+                provides=ISearchFormFactory)
+def shared_tool_advanced_search_form_factory(context, request, view):
+    """Elasticsearch advanced search form factory"""
+    return EsSharedToolAdvancedSearchForm(context, request)
+
+
+@adapter_config(context=(IBaseSharedTool, IPyAMSLayer, EsSharedToolAdvancedSearchForm),
+                provides=IContentSearch)
+class EsSharedToolAdvancedSearchFormSearchAdapter(ContextRequestViewAdapter):
+    """Elasticsearch shared tool advanced search form adapter"""
+
+    def get_search_results(self, data):
+        intids = get_utility(IIntIds)
+        params = Q('term', parent_ids=intids.register(self.context)) & \
+            Q('terms', content_type=list(CONTENT_TYPES.keys()))
+        # check query settings
+        query = data.get('query')
+        if query:
+            sequence = get_utility(ISequentialIntIds)
+            if query.startswith('+'):
+                params &= Q('term', reference_id=sequence.get_full_oid(query))
+            else:
+                fulltext = data.get('fulltext', False)
+                if fulltext:
+                    params &= Q('simple_query_string', query=query)
+                else:
+                    negotiator = get_utility(INegotiator)
+                    query_params = Q('bool')
+                    for lang in {self.request.registry.settings.get('pyramid.default_locale_name',
+                                                                    'en'),
+                                 self.request.locale_name,
+                                 negotiator.server_language} | negotiator.offered_languages:
+                        query_params |= Q('match', **{'title.{}'.format(lang): query})
+                        query_params |= Q('match', **{'short_name.{}'.format(lang): query})
+                        query_params |= Q('match', **{'header.{}'.format(lang): query})
+                        query_params |= Q('match', **{'description.{}'.format(lang): query})
+                    params &= query_params
+        if data.get('owner'):
+            params &= Q('term', owner_id=data['owner'])
+        if data.get('status'):
+            params &= Q('term', workflow__status=data['status'])
+        if data.get('data_type'):
+            params &= Q('term', data_type=data['data_type'])
+        if data.get('created_after'):
+            params &= Q('range', workflow__created_date={'gte': data['created_after']})
+        if data.get('created_before'):
+            params &= Q('range', workflow__created_date={'lte': data['created_before']})
+        if data.get('modified_after'):
+            params &= Q('range', workflow__modified_date={'gte': data['modified_after']})
+        if data.get('modified_before'):
+            params &= Q('range', workflow__modified_date={'lte': data['modified_before']})
+        if data.get('tags'):
+            tags = [intids.register(term) for term in data['tags']]
+            params &= Q('terms', tags=tags)
+        if data.get('themes'):
+            themes = [intids.register(term) for term in data['themes']]
+            params &= Q('terms', themes__terms=themes)
+        if data.get('collections'):
+            collections = [intids.register(collection) for collection in data['collections']]
+            params &= Q('terms', collections=collections)
+        # get ES client and create search object
+        client = get_client(self.request)
+        search = Search(using=client.es, index=client.index) \
+            .params(request_timeout=30) \
+            .query(params) \
+            .source(['internal_id'])
+        sort_values = [{
+            'workflow.modified_date': {
+                'unmapped_type': 'date',
+                'order': 'desc'
+            }
+        }]
+        if query:
+            sort_values.insert(0, {
+                '_score': {
+                    'order': 'desc'
+                }
+            })
+        search = search.sort(*sort_values)[:999]
+        # extract results
+        if data.get('status'):
+            # if status is specified, only extract last version in given status
+            return unique(map(lambda x: sorted(IWorkflowVersions(x).get_versions(data['status']),
+                                               key=lambda y: IZopeDublinCore(y).modified)[0],
+                              CatalogResultSet([result.internal_id for result in search])))
+        else:
+            # otherwise, extract last absolute version
+            return unique(map(get_last_version,
+                              CatalogResultSet([result.internal_id for result in search])))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content_es/shared/zmi/templates/advanced-search.pt	Sun Jul 19 02:11:57 2020 +0200
@@ -0,0 +1,265 @@
+<div class="ams-widget" i18n:domain="pyams_content_es">
+	<header>
+		<span tal:condition="view.widget_icon_class | nothing"
+			  class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
+		</span>
+		<h2 tal:content="view.legend"></h2>
+		<tal:var content="structure provider:pyams.widget_title" />
+		<tal:var content="structure provider:pyams.toolbar" />
+	</header>
+	<div class="widget-body no-padding">
+		<div tal:define="prefix provider:form_prefix"
+			 tal:replace="structure prefix">Form prefix</div>
+		<tal:var content="structure provider:content_help" />
+		<form method="post"
+			  data-async
+			  tal:attributes="id view.id;
+							  name view.name;
+							  action view.get_form_action();
+							  method view.method;
+							  enctype view.enctype;
+							  acceptCharset view.acceptCharset;
+							  accept view.accept;
+							  autocomplete view.autocomplete;
+							  class view.css_class;
+							  data-ams-data tales:object_data(view);
+							  data-ams-form-handler view.get_ajax_handler() | nothing;
+							  data-ams-form-options view.get_form_options() | nothing;
+							  data-ams-form-submit-target view.form_target | nothing;
+							  data-ams-form-download-target view.download_target | nothing;
+							  data-ams-warn-on-change view.warn_on_change;">
+			<div class="modal-viewport">
+				<fieldset>
+					<div class="widgets-prefix"
+						 tal:define="prefix provider:widgets_prefix"
+						 tal:condition="prefix"
+						 tal:content="structure prefix">Widgets prefix</div>
+					<tal:loop repeat="group view.groups">
+						<fieldset tal:define="legend group.legend"
+								  tal:omit-tag="not:legend"
+								  tal:attributes="class 'bordered' if group.bordered else None">
+							<tal:if condition="group.checkbox_switch">
+								<legend data-ams-checker-value="selected"
+										tal:condition="legend"
+										tal:attributes="class group.css_class;
+														data-ams-checker-fieldname '{0}:list'.format(group.checkbox_widget.name);
+														data-ams-checker-readonly 'readonly' if group.checkbox_widget.mode == 'display' else None;
+														data-ams-checker-marker '{0}-empty-marker'.format(group.checkbox_widget.name);
+														data-ams-checker-state group.checker_state;">
+									<label tal:content="legend">Legend</label>
+								</legend>
+							</tal:if>
+							<tal:if condition="not:group.checkbox_switch">
+								<legend tal:condition="legend"
+										tal:content="legend"
+										tal:attributes="class group.css_class;
+														data-ams-switcher-state group.switcher_state;">Legend</legend>
+							</tal:if>
+							<tal:var define="help group.help" condition="help">
+								<div class=""
+									 tal:define="html import:pyams_utils.text.text_to_html;
+												 i18n_help html(request.localizer.translate(help));"
+									 tal:content="structure i18n_help"></div>
+							</tal:var>
+							<div class="form-group" tal:define="widget view.widgets['query']">
+								<label class="control-label col-md-3">
+									<span>
+										<tal:var content="widget.label" />
+										<i class="fa fa-question-circle hint" title="Input hint"
+										   tal:define="description widget.field.description"
+										   tal:condition="description"
+										   tal:attributes="title description;
+														   data-ams-hint-html '<' in description;"></i>
+									</span>
+								</label>
+								<div class="col-md-9">
+									<div class="input"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+									<div class="input padding-10"
+										 tal:define="fulltext view.widgets['fulltext']"
+										 tal:attributes="class fulltext.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(fulltext.field.getName())">
+										<input tal:replace="structure fulltext.render()" />
+									</div>
+								</div>
+							</div>
+							<div class="form-group">
+								<tal:var define="widget view.widgets['owner']">
+									<label class="control-label col-md-3">
+										<span>
+											<tal:var content="widget.label" />
+											<i class="fa fa-question-circle hint" title="Input hint"
+											   tal:define="description widget.field.description"
+											   tal:condition="description"
+											   tal:attributes="title description;
+															   data-ams-hint-html '<' in description;"></i>
+										</span>
+									</label>
+									<div class="col-md-4">
+										<div class="input"
+											 tal:attributes="class widget.widget_css_class | default;
+															 data-ams-data tales:object_data(widget);
+															 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+											<input tal:replace="structure widget.render()" />
+										</div>
+									</div>
+								</tal:var>
+							</div>
+							<div class="form-group">
+								<tal:var define="widget view.widgets['status']">
+									<label class="control-label col-md-3">
+										<span>
+											<tal:var content="widget.label" />
+											<i class="fa fa-question-circle hint" title="Input hint"
+											   tal:define="description widget.field.description"
+											   tal:condition="description"
+											   tal:attributes="title description;
+															   data-ams-hint-html '<' in description;"></i>
+										</span>
+									</label>
+									<div class="col-md-4">
+										<div class="input"
+											 tal:attributes="class widget.widget_css_class | default;
+															 data-ams-data tales:object_data(widget);
+															 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+											<input tal:replace="structure widget.render()" />
+										</div>
+									</div>
+								</tal:var>
+								<tal:if define="widget view.widgets.get('data_type')"
+										condition="widget">
+									<tal:var >
+										<label class="control-label col-md-1">
+											<span>
+												<tal:var content="widget.label" />
+												<i class="fa fa-question-circle hint" title="Input hint"
+												   tal:define="description widget.field.description"
+												   tal:condition="description"
+												   tal:attributes="title description;
+																   data-ams-hint-html '<' in description;"></i>
+											</span>
+										</label>
+										<div class="col-md-4">
+											<div class="input"
+												 tal:attributes="class widget.widget_css_class | default;
+																 data-ams-data tales:object_data(widget);
+																 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+												<input tal:replace="structure widget.render()" />
+											</div>
+										</div>
+									</tal:var>
+								</tal:if>
+							</div>
+							<div class="form-group">
+								<label class="control-label col-md-3">
+									<span i18n:translate="">Created between</span>
+								</label>
+								<div class="col-md-4">
+									<div class="input"
+										 tal:define="widget view.widgets['created_after']"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+								</div>
+								<div class="control-label col-md-1 text-align-center">
+									<i18n:var translate=""> and </i18n:var>
+								</div>
+								<div class="col-md-4">
+									<div class="input"
+										 tal:define="widget view.widgets['created_before']"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+								</div>
+							</div>
+							<div class="form-group">
+								<label class="control-label col-md-3">
+									<span i18n:translate="">Modified between</span>
+								</label>
+								<div class="col-md-4">
+									<div class="input"
+										 tal:define="widget view.widgets['modified_after']"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+								</div>
+								<div class="control-label col-md-1 text-align-center">
+									<i18n:var translate=""> and </i18n:var>
+								</div>
+								<div class="col-md-4">
+									<div class="input"
+										 tal:define="widget view.widgets['modified_before']"
+										 tal:attributes="class widget.widget_css_class | default;
+														 data-ams-data tales:object_data(widget);
+														 data-ams-form-validator view.get_widget_callback(widget.field.getName())">
+										<input tal:replace="structure widget.render()" />
+									</div>
+								</div>
+							</div>
+							<tal:loop repeat="fieldname ('tags', 'themes', 'collections')">
+								<fieldset tal:condition="fieldname in view.widgets">
+									<tal:var define="widget view.widgets[fieldname]">
+										<legend class="switcher">${widget.label}</legend>
+										<div class="input">
+											<input tal:replace="structure widget.render()" />
+										</div>
+									</tal:var>
+								</fieldset>
+							</tal:loop>
+						</fieldset>
+					</tal:loop>
+					<div class="widgets-suffix"
+						 tal:define="suffix provider:widgets_suffix"
+						 tal:condition="suffix"
+						 tal:content="structure suffix">Widgets suffix</div>
+					<div class="subforms"
+						 tal:condition="view.subforms">
+						<fieldset tal:define="title view.subforms_legend"
+								  tal:omit-tag="not:title">
+							<legend tal:condition="title" tal:content="title" i18n:translate="">Title</legend>
+							<tal:loop repeat="subform view.subforms">
+								<tal:var replace="structure subform.render()" />
+							</tal:loop>
+						</fieldset>
+					</div>
+					<div class="tabforms"
+						 tal:condition="view.tabforms">
+						<ul class="nav nav-tabs">
+							<li tal:repeat="tabform view.tabforms"
+								tal:attributes="class 'small {active} {errors}'.format(active='active' if repeat['tabform'].start() else '',
+																					   errors='state-error' if tabform.widgets.errors else '')">
+								<a data-toggle="tab"
+								   tal:attributes="href string:#${tabform.id}"
+								   tal:content="tabform.tab_label" i18n:translate="">Tab label</a>
+							</li>
+						</ul>
+						<div class="tab-content">
+							<div class="tab-pane fade in"
+								 tal:repeat="tabform view.tabforms"
+								 tal:attributes="id tabform.id;
+												 class 'tab-pane {active} fade in'.format(active='active' if repeat['tabform'].start() else '');"
+								 tal:content="structure tabform.render()"></div>
+						</div>
+					</div>
+				</fieldset>
+			</div>
+			<footer>
+				<button tal:repeat="action view.actions.values()"
+						tal:replace="structure action.render()">Action</button>
+			</footer>
+		</form>
+		<div tal:define="prefix provider:form_suffix"
+			 tal:replace="structure prefix">Form suffix</div>
+	</div>
+</div>