From 8e044b018ae602ffe9e69a2285596ed7a0da057e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Thu, 14 Apr 2022 21:50:32 +0200
Subject: [PATCH] backoffice: add an inspect view to forms/card models (#30381)

---
 tests/admin_pages/test_form.py                | 122 +++++++++++++-
 wcs/admin/forms.py                            |  67 ++++++--
 wcs/categories.py                             |   9 +
 wcs/fields.py                                 | 159 +++++++++++++++++-
 wcs/formdef.py                                |   3 +
 wcs/qommon/static/css/dc2/admin.scss          |  47 +++++-
 .../wcs/backoffice/formdef-inspect.html       |  86 ++++++++++
 7 files changed, 464 insertions(+), 29 deletions(-)
 create mode 100644 wcs/templates/wcs/backoffice/formdef-inspect.html

diff --git a/tests/admin_pages/test_form.py b/tests/admin_pages/test_form.py
index 9a3908809..942ba55ef 100644
--- a/tests/admin_pages/test_form.py
+++ b/tests/admin_pages/test_form.py
@@ -16,7 +16,7 @@ from wcs.data_sources import NamedDataSource
 from wcs.formdef import FormDef
 from wcs.qommon.errors import ConnectionError
 from wcs.qommon.http_request import HTTPRequest
-from wcs.workflows import Workflow, WorkflowBackofficeFieldsFormDef
+from wcs.workflows import Workflow, WorkflowBackofficeFieldsFormDef, WorkflowVariablesFieldsFormDef
 from wcs.wscalls import NamedWsCall
 
 from ..utilities import clean_temporary_pub, create_temporary_pub, get_app, login
@@ -895,8 +895,6 @@ def test_form_workflow_variables(pub):
 
     Workflow.wipe()
     workflow = Workflow(name='Workflow One')
-    from wcs.workflows import WorkflowVariablesFieldsFormDef
-
     workflow.variables_formdef = WorkflowVariablesFieldsFormDef(workflow=workflow)
     workflow.variables_formdef.fields.append(
         fields.StringField(id='1', varname='test', label='Test', type='string')
@@ -947,8 +945,6 @@ def test_form_workflow_table_variables(pub):
 
     Workflow.wipe()
     workflow = Workflow(name='Workflow One')
-    from wcs.workflows import WorkflowVariablesFieldsFormDef
-
     workflow.variables_formdef = WorkflowVariablesFieldsFormDef(workflow=workflow)
     workflow.variables_formdef.fields.append(
         fields.TableRowsField(id='1', varname='test', label='Test2', type='tablerows', columns=['a'])
@@ -996,8 +992,6 @@ def test_form_workflow_invalid_file_variable(pub):
     Workflow.wipe()
 
     workflow = Workflow(name='Workflow One')
-    from wcs.workflows import WorkflowVariablesFieldsFormDef
-
     workflow.variables_formdef = WorkflowVariablesFieldsFormDef(workflow=workflow)
     workflow.variables_formdef.fields = [
         fields.StringField(id='1', varname='test', label='Test', type='string')
@@ -3421,3 +3415,117 @@ def test_field_display_locations_statistics_choice(pub):
         resp.form['varname'] = 'var_%s' % i
         resp = resp.form.submit('submit')
         assert 'statistics' in FormDef.get(formdef.id).fields[i].display_locations
+
+
+def test_admin_form_inspect(pub):
+    create_superuser(pub)
+    create_role(pub)
+
+    NamedDataSource.wipe()
+    data_source = NamedDataSource(name='Foobar')
+    data_source.data_source = {'type': 'json', 'value': 'http://remote.example.net/404'}
+    data_source.store()
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'Baz'
+    carddef.digest_templates = {'default': 'plop'}
+    carddef.store()
+
+    Workflow.wipe()
+    workflow = Workflow(name='Workflow One')
+    workflow.variables_formdef = WorkflowVariablesFieldsFormDef(workflow=workflow)
+    workflow.variables_formdef.fields.append(
+        fields.StringField(id='1', varname='test', label='Test', type='string')
+    )
+    workflow.store()
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.workflow_id = workflow.id
+    formdef.fields = [
+        fields.PageField(
+            id='0',
+            label='1st page',
+            type='page',
+            post_conditions=[
+                {'condition': {'type': 'django', 'value': 'false'}, 'error_message': 'You shall not pass.'}
+            ],
+        ),
+        fields.StringField(
+            id='1', label='String field', varname='var_1', condition={'type': 'django', 'value': 'true'}
+        ),
+        fields.StringField(id='2', label='String field digits', validation={'type': 'digits'}),
+        fields.StringField(id='3', label='String field regex', validation={'type': 'regex', 'value': r'\d+'}),
+        fields.ItemField(
+            id='4',
+            label='Date field',
+            type='date',
+        ),
+        fields.ItemField(id='5', label='Item field', type='item', items=['One', 'Two', 'Three']),
+        fields.ItemField(
+            id='6', label='Item field named data source', type='item', data_source={'type': 'foobar'}
+        ),
+        fields.ItemField(
+            id='7', label='Item field carddef data source', type='item', data_source={'type': 'carddef:baz'}
+        ),
+        fields.ItemField(
+            id='8',
+            label='Item field json data source',
+            type='item',
+            data_source={'type': 'json', 'value': 'http://test'},
+        ),
+        fields.ItemsField(id='9', label='Items field', type='items', items=['One', 'Two', 'Three']),
+        fields.StringField(id='10', label='prefill', prefill={'type': 'user', 'value': 'email'}),
+        fields.StringField(
+            id='11', label='prefill2', prefill={'type': 'string', 'value': '{{plop}}', 'locked': True}
+        ),
+        fields.FileField(
+            id='12', label='file', automatic_image_resize=True, display_locations=['validation']
+        ),
+    ]
+    formdef.workflow_options = {'test': 'plop'}
+    formdef.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/%s/inspect' % formdef.id)
+
+    assert 'Test → plop' in resp.text  # workflow option
+    assert (
+        resp.pyquery('[data-field-id="0"] .parameter-post_conditions').text()
+        == 'Post Conditions:\nfalse - You shall not pass.'
+    )
+    assert (
+        resp.pyquery('[data-field-id="1"] .parameter-condition').text() == 'Display Condition: true (Django)'
+    )
+    assert resp.pyquery('[data-field-id="2"] .parameter-validation').text() == 'Validation: Digits'
+    assert (
+        resp.pyquery('[data-field-id="3"] .parameter-validation').text()
+        == 'Validation: Regular Expression - \\d+'
+    )
+    assert resp.pyquery('[data-field-id="5"] .parameter-items').text() == 'Choices: One, Two, Three'
+    assert resp.pyquery('[data-field-id="6"] .parameter-data_source').text() == 'Data source: Foobar'
+    assert (
+        resp.pyquery('[data-field-id="7"] .parameter-data_source').text() == 'Data source: card model - Baz'
+    )
+    assert (
+        resp.pyquery('[data-field-id="8"] .parameter-data_source').text()
+        == 'Data source: JSON URL - http://test'
+    )
+    assert (
+        resp.pyquery('[data-field-id="10"] .parameter-prefill').text()
+        == 'Prefill:\nType: User Field\nValue: Email (builtin)'
+    )
+    assert (
+        resp.pyquery('[data-field-id="11"] .parameter-prefill').text()
+        == 'Prefill:\nType: String / Template\nValue: {{plop}}\nLocked'
+    )
+    assert (
+        resp.pyquery('[data-field-id="12"] .parameter-automatic_image_resize').text()
+        == 'Automatically resize uploaded images: Yes'
+    )
+    assert (
+        resp.pyquery('[data-field-id="12"] .parameter-display_locations').text()
+        == 'Display Locations: Validation Page'
+    )
diff --git a/wcs/admin/forms.py b/wcs/admin/forms.py
index ac9e209cf..4e5831d9e 100644
--- a/wcs/admin/forms.py
+++ b/wcs/admin/forms.py
@@ -596,6 +596,7 @@ class WorkflowRoleDirectory(Directory):
 
 
 class FormDefPage(Directory):
+    do_not_call_in_templates = True
     _q_exports = [
         '',
         'fields',
@@ -616,6 +617,7 @@ class FormDefPage(Directory):
         'overwrite',
         'qrcode',
         'information',
+        'inspect',
         ('public-url', 'public_url'),
         ('backoffice-submission-roles', 'backoffice_submission_roles'),
         ('logged-errors', 'logged_errors_dir'),
@@ -640,6 +642,7 @@ class FormDefPage(Directory):
     )
     readonly_message = _('This form is readonly.')
     management_view_label = _('Management view')
+    inspect_template_name = 'wcs/backoffice/formdef-inspect.html'
 
     def __init__(self, component, instance=None):
         try:
@@ -758,24 +761,10 @@ class FormDefPage(Directory):
                     r += self.add_option_line('workflow-options', _('Options'), '')
 
         if self.formdef.workflow.roles:
-            if not self.formdef.workflow_roles:
-                self.formdef.workflow_roles = {}
-            workflow_roles = list((self.formdef.workflow.roles or {}).items())
-            workflow_roles.sort(key=lambda x: '' if x[0] == '_receiver' else misc.simplify(x[1]))
-            for (wf_role_id, wf_role_label) in workflow_roles:
-                role_id = self.formdef.workflow_roles.get(wf_role_id)
-                if role_id:
-                    try:
-                        role = get_publisher().role_class.get(role_id)
-                        role_label = role.name
-                    except KeyError:
-                        # removed role ?
-                        role_label = _('Unknown role (%s)') % role_id
-                else:
-                    role_label = '-'
+            for (wf_role_id, wf_role_label, role_label) in self.get_workflow_roles_elements():
                 r += self.add_option_line('role/%s' % wf_role_id, wf_role_label, role_label)
 
-        r += self.add_option_line('roles', _('User Roles'), self._get_roles_label_and_auth_context('roles'))
+        r += self.add_option_line('roles', _('User Roles'), self.get_roles_label_and_auth_context())
         r += self.add_option_line(
             'backoffice-submission-roles',
             _('Backoffice Submission Role'),
@@ -880,6 +869,24 @@ class FormDefPage(Directory):
         r += htmltext('</div>')
         return r.getvalue()
 
+    def get_workflow_roles_elements(self):
+        if not self.formdef.workflow_roles:
+            self.formdef.workflow_roles = {}
+        workflow_roles = list((self.formdef.workflow.roles or {}).items())
+        workflow_roles.sort(key=lambda x: '' if x[0] == '_receiver' else misc.simplify(x[1]))
+        for (wf_role_id, wf_role_label) in workflow_roles:
+            role_id = self.formdef.workflow_roles.get(wf_role_id)
+            if role_id:
+                try:
+                    role = get_publisher().role_class.get(role_id)
+                    role_label = role.name
+                except KeyError:
+                    # removed role ?
+                    role_label = _('Unknown role (%s)') % role_id
+            else:
+                role_label = '-'
+            yield (wf_role_id, wf_role_label, role_label)
+
     def _get_roles_label(self, attribute):
         if getattr(self.formdef, attribute):
             roles = []
@@ -897,8 +904,8 @@ class FormDefPage(Directory):
             value = C_('roles|None')
         return value
 
-    def _get_roles_label_and_auth_context(self, attribute):
-        value = self._get_roles_label(attribute)
+    def get_roles_label_and_auth_context(self):
+        value = self._get_roles_label('roles')
         if self.formdef.required_authentication_contexts:
             auth_contexts = get_publisher().get_supported_authentication_contexts()
             value += ' (%s)' % ', '.join(
@@ -924,6 +931,7 @@ class FormDefPage(Directory):
         if get_publisher().snapshot_class:
             r += htmltext('<li><a rel="popup" href="history/save">%s</a></li>') % _('Save snapshot')
             r += htmltext('<li><a href="history/">%s</a></li>') % _('History')
+        r += htmltext('<li><a href="inspect">%s</a></li>') % _('Inspector')
         if not get_publisher().is_using_postgresql() and self.formdef_class == FormDef:
             r += htmltext('<li><a href="archive">%s</a></li>') % _('Archive')
         r += htmltext('</ul>')
@@ -1742,6 +1750,29 @@ class FormDefPage(Directory):
             self.formdef.workflow_options[widget.name] = widget.parse()
         self.formdef.store(comment=_('Change in workflow options'))
 
+    def inspect(self):
+        self.html_top(self.formdef.name)
+        get_response().breadcrumb.append(('inspect', _('Inspector')))
+        context = {'formdef': self.formdef, 'view': self}
+        if self.formdef.workflow.variables_formdef:
+            context['workflow_options'] = {}
+            variables_form_data = self.formdef.get_variable_options_for_form()
+            for field in self.formdef.workflow.variables_formdef.fields:
+                context['workflow_options'][field.label] = htmltext('%s') % field.get_view_value(
+                    variables_form_data.get(field.id)
+                )
+        context['workflow_roles'] = list(self.get_workflow_roles_elements())
+        context['backoffice_submission_roles'] = self._get_roles_label('backoffice_submission_roles')
+        if self.formdef.tracking_code_verify_fields:
+            context['tracking_code_verify_fields_labels'] = ', '.join(
+                [
+                    x.label
+                    for x in self.formdef.fields
+                    if str(x.id) in self.formdef.tracking_code_verify_fields
+                ]
+            )
+        return template.QommonTemplateResponse(templates=[self.inspect_template_name], context=context)
+
 
 class NamedDataSourcesDirectoryInForms(NamedDataSourcesDirectory):
     pass
diff --git a/wcs/categories.py b/wcs/categories.py
index e3872d1c0..dc37c13ed 100644
--- a/wcs/categories.py
+++ b/wcs/categories.py
@@ -30,6 +30,7 @@ class Category(XmlStorableObject):
     _names = 'categories'
     xml_root_node = 'category'
     backoffice_class = 'wcs.admin.categories.CategoryPage'
+    backoffice_base_url = 'forms/categories/'
 
     name = None
     url_name = None
@@ -92,6 +93,9 @@ class Category(XmlStorableObject):
             return True
         return False
 
+    def get_admin_url(self):
+        return '%s/%s%s/' % (get_publisher().get_backoffice_url(), self.backoffice_base_url, self.id)
+
     def store(self, *args, comment=None, snapshot_store_user=True, **kwargs):
         if not self.url_name:
             existing_slugs = {
@@ -214,6 +218,7 @@ class CardDefCategory(Category):
     _names = 'carddef_categories'
     xml_root_node = 'carddef_category'
     backoffice_class = 'wcs.admin.categories.CardDefCategoryPage'
+    backoffice_base_url = 'cards/categories/'
 
     # declarations for serialization
     XML_NODES = [
@@ -236,6 +241,7 @@ class WorkflowCategory(Category):
     _names = 'workflow_categories'
     xml_root_node = 'workflow_category'
     backoffice_class = 'wcs.admin.categories.WorkflowCategoryPage'
+    backoffice_base_url = 'workflows/categories/'
 
     # declarations for serialization
     XML_NODES = [
@@ -257,6 +263,7 @@ class BlockCategory(Category):
     _names = 'block_categories'
     xml_root_node = 'block_category'
     backoffice_class = 'wcs.admin.categories.BlockCategoryPage'
+    backoffice_base_url = 'forms/blocks/categories/'
 
     # declarations for serialization
     XML_NODES = [
@@ -277,6 +284,7 @@ class MailTemplateCategory(Category):
     _names = 'mail_template_categories'
     xml_root_node = 'mail_template_category'
     backoffice_class = 'wcs.admin.categories.MailTemplateCategoryPage'
+    backoffice_base_url = 'workflows/mail-templates/categories/'
 
     # declarations for serialization
     XML_NODES = [
@@ -297,6 +305,7 @@ class DataSourceCategory(Category):
     _names = 'data_source_categories'
     xml_root_node = 'data_source_category'
     backoffice_class = 'wcs.admin.categories.DataSourceCategoryPage'
+    backoffice_base_url = 'forms/data-sources/categories/'
 
     # declarations for serialization
     XML_NODES = [
diff --git a/wcs/fields.py b/wcs/fields.py
index 6b8553e87..9f6a26fdc 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -48,6 +48,7 @@ from .qommon.form import (
     EmailWidget,
     FileSizeWidget,
     FileWithPreviewWidget,
+    Form,
     HiddenWidget,
     HtmlWidget,
     IntWidget,
@@ -137,7 +138,7 @@ class PrefillSelectionWidget(CompositeWidget):
         if not self.value or self.value.get('type') == 'none':
             self.value = {}
 
-        prefill_types = collections.OrderedDict(options)
+        self.prefill_types = prefill_types = collections.OrderedDict(options)
         self.add(
             StringWidget,
             'value_string',
@@ -691,6 +692,129 @@ class Field:
 
                 yield NamedDataSource.get_by_slug(data_source_type, ignore_errors=True)
 
+    def get_parameters_view(self):
+        r = TemplateIO(html=True)
+        form = Form()
+        self.fill_admin_form(form)
+        parameters = [x for x in self.get_admin_attributes() if getattr(self, x, None) is not None]
+        r += htmltext('<ul>')
+        for parameter in parameters:
+            widget = form.get_widget(parameter)
+            if not widget:
+                continue
+            label = self.get_parameter_view_label(widget, parameter)
+            if not label:
+                continue
+            value = getattr(self, parameter, Ellipsis)
+            if value is None or value == getattr(self.__class__, parameter, Ellipsis):
+                continue
+            parameter_view_value = self.get_parameter_view_value(widget, parameter)
+            if parameter_view_value:
+                r += htmltext('<li class="parameter-%s">' % parameter)
+                r += htmltext('<span class="parameter">%s</span> ') % _('%s:') % label
+                r += parameter_view_value
+                r += htmltext('</li>')
+        r += htmltext('</ul>')
+        return r.getvalue()
+
+    def get_parameter_view_label(self, widget, parameter):
+        if hasattr(self, 'get_%s_parameter_view_label' % parameter):
+            return getattr(self, 'get_%s_parameter_view_label' % parameter)()
+        return widget.get_title()
+
+    def get_parameter_view_value(self, widget, parameter):
+        if hasattr(self, 'get_%s_parameter_view_value' % parameter):
+            return getattr(self, 'get_%s_parameter_view_value' % parameter)(widget)
+        value = getattr(self, parameter)
+        if isinstance(value, bool):
+            return str(_('Yes') if value else _('No'))
+        elif hasattr(widget, 'options') and value:
+            if not isinstance(widget, CheckboxesWidget):
+                value = [value]
+            value_labels = []
+            for option in widget.options:
+                if isinstance(option, tuple):
+                    if option[0] in value:
+                        value_labels.append(str(option[1]))
+                else:
+                    if option in value:
+                        value_labels.append(str(option))
+            return ', '.join(value_labels) if value_labels else '-'
+        elif isinstance(value, list):
+            return ', '.join(value)
+        else:
+            return str(value)
+
+    def get_prefill_parameter_view_value(self, widget):
+        value = getattr(self, 'prefill', None)
+        if not value or value.get('type') == 'none':
+            return
+        r = TemplateIO(html=True)
+        r += htmltext('<ul>')
+        r += htmltext('<li><span class="parameter">%s%s</span> %s</li>') % (
+            _('Type'),
+            _(':'),
+            widget.prefill_types.get(value.get('type')),
+        )
+        if value.get('type') in ('user', 'geolocation'):
+            select_widget = widget.get_widget('value_%s' % value['type'])
+            labels = {x[0]: x[1] for x in select_widget.options}
+            r += htmltext('<li><span class="parameter">%s%s</span> %s</li>') % (
+                _('Value'),
+                _(':'),
+                labels.get(value.get('value'), '-'),
+            )
+        else:
+            r += htmltext('<li><span class="parameter">%s%s</span> %s</li>') % (
+                _('Value'),
+                _(':'),
+                value.get('value'),
+            )
+        if value.get('locked'):
+            r += htmltext('<li>%s</li>') % _('Locked')
+        r += htmltext('</ul>')
+        return r.getvalue()
+
+    def get_data_source_parameter_view_value(self, widget):
+        value = getattr(self, 'data_source', None)
+        if not value or value.get('type') == 'none':
+            return
+
+        if value.get('type').startswith('carddef:'):
+            from wcs.carddef import CardDef
+
+            parts = value['type'].split(':')
+            try:
+                carddef = CardDef.get_by_urlname(parts[1])
+            except KeyError:
+                return _('deleted card model')
+            custom_view = CardDef.get_data_source_custom_view(value['type'], carddef=carddef)
+            if custom_view:
+                return _('card model: %s, custom view: %s') % (carddef.name, custom_view.title)
+            return '%s - %s' % (_('card model'), carddef.name)
+
+        data_source_types = {
+            'json': _('JSON URL'),
+            'jsonp': _('JSONP URL'),
+            'geojson': _('GeoJSON URL'),
+            'formula': _('Python Expression'),
+        }
+        if value.get('type') in data_source_types:
+            return '%s - %s' % (data_source_types[value.get('type')], value.get('value'))
+
+        from wcs.data_sources import NamedDataSource
+
+        data_source = NamedDataSource.get_by_slug(value['type'])
+        return data_source.name
+
+    def get_condition_parameter_view_value(self, widget):
+        if not self.condition or self.condition.get('type') == 'none':
+            return
+        return htmltext('<tt class="condition">%s</tt> <span class="condition-type">(%s)</span>') % (
+            self.condition['value'],
+            {'django': 'Django', 'python': 'Python'}.get(self.condition['type']),
+        )
+
     def __repr__(self):
         return '<%s %s %r>' % (self.__class__.__name__, self.id, self.label and self.label[:64])
 
@@ -1169,6 +1293,15 @@ class StringField(WidgetField):
         super().init_with_xml(elem, charset, include_id=include_id)
         self.migrate()
 
+    def get_validation_parameter_view_value(self, widget):
+        if not self.validation:
+            return
+        validation_type = self.validation['type']
+        validation_types = {x: y['title'] for x, y in ValidationWidget.validation_methods.items()}
+        if validation_type in ('regex', 'django'):
+            return '%s - %s' % (validation_types.get(validation_type), self.validation['value'])
+        return str(validation_types.get(validation_type))
+
 
 register_field_class(StringField)
 
@@ -1906,6 +2039,15 @@ class ItemFieldMixin:
             data_source_type.set_value(None)
             data_source_type.transfer_form_value(get_request())
 
+    def get_items_parameter_view_label(self):
+        if self.data_source:
+            # skip field if there's a data source
+            return None
+        return str(_('Choices'))
+
+    def get_data_source_parameter_view_label(self):
+        return str(_('Data source'))
+
 
 class ItemField(WidgetField, MapOptionsMixin, ItemFieldMixin):
     key = 'item'
@@ -2226,8 +2368,8 @@ class ItemField(WidgetField, MapOptionsMixin, ItemFieldMixin):
 
     def get_admin_attributes(self):
         return WidgetField.get_admin_attributes(self) + [
-            'items',
             'display_mode',
+            'items',
             'data_source',
             'in_filters',
             'anonymise',
@@ -2742,6 +2884,19 @@ class PageField(Field):
         for post_condition in self.post_conditions or []:
             yield post_condition.get('condition')
 
+    def get_post_conditions_parameter_view_value(self, widget):
+        if not self.post_conditions:
+            return
+        r = TemplateIO(html=True)
+        r += htmltext('<ul>')
+        for post_condition in self.post_conditions:
+            r += htmltext('<li>%s - %s</li>') % (
+                post_condition.get('condition').get('value'),
+                post_condition.get('error_message'),
+            )
+        r += htmltext('</ul>')
+        return r.getvalue()
+
 
 register_field_class(PageField)
 
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 0fb5c3473..04245d8a2 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -873,6 +873,9 @@ class FormDef(StorableObject):
     def get_page(self, page_no):
         return [x for x in self.fields if x.type == 'page'][page_no]
 
+    def page_count(self):
+        return len([x for x in self.fields if x.type == 'page']) or 1
+
     def create_view_form(self, dict=None, use_tokens=True, visible=True):
         dict = dict or {}
         form = Form(enctype='multipart/form-data', use_tokens=use_tokens)
diff --git a/wcs/qommon/static/css/dc2/admin.scss b/wcs/qommon/static/css/dc2/admin.scss
index 0948215d2..920baa18c 100644
--- a/wcs/qommon/static/css/dc2/admin.scss
+++ b/wcs/qommon/static/css/dc2/admin.scss
@@ -1662,8 +1662,38 @@ div.expanded-statuses div.status h4::before {
 	font-size: 90%;
 }
 
-div.expanded-statuses div.status ul span.parameter {
-	color: #666;
+div.inspect-form-tabs,
+div.inspect-field,
+div.expanded-statuses div.status {
+	ul span.parameter {
+		color: #666;
+	}
+}
+
+.inspect-field {
+	margin: 0.5em 0 1em 0;
+	h4 {
+		margin: 0.5em 0;
+		&:first-child {
+			margin-top: 0;
+		}
+	}
+	&.inspect-field--page:not(.inspect-field--first) {
+		border-top: 1px solid #ccc;
+		padding-top: 0.5em;
+	}
+}
+
+[role=tabpanel] {
+	> .pk-information:first-child {
+		margin-top: 0;
+	}
+}
+
+.condition-type,
+.inspect-field-type {
+	font-weight: normal;
+	font-size: 90%;
 }
 
 pre.wrapping-pre {
@@ -1702,6 +1732,19 @@ form div.page > div {
 	}
 }
 
+.inspect-tabs {
+	ul {
+		margin: 0;
+		padding: 0;
+	}
+	li {
+		margin-left: 1em;
+		&::marker {
+			color: #666;
+		}
+	}
+}
+
 ul.biglist.form-inspector {
 	padding-top: 0;
 }
diff --git a/wcs/templates/wcs/backoffice/formdef-inspect.html b/wcs/templates/wcs/backoffice/formdef-inspect.html
new file mode 100644
index 000000000..1da69cb5c
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/formdef-inspect.html
@@ -0,0 +1,86 @@
+{% extends "wcs/backoffice/base.html" %}
+{% load i18n %}
+
+{% block appbar %}{% endblock %}
+
+{% block content %}
+
+<div class="pk-tabs inspect-tabs inspect-form-tabs">
+ <div class="pk-tabs--tab-list" role="tablist">
+  <button role="tab" aria-selected="true" aria-controls="inspect-infos" id="tab-infos" tabindex="0">{% trans "Information" %}</button>
+  <button role="tab" aria-selected="false" aria-controls="inspect-workflow" id="tab-workflow" tabindex="-1">{% trans "Workflow" %}</button>
+  <button role="tab" aria-selected="false" aria-controls="inspect-options" id="tab-options" tabindex="-1">{% trans "Options" %}</button>
+  <button role="tab" aria-selected="false" aria-controls="inspect-fields" id="tab-fields" tabindex="-1">{% trans "Fields" %}</button>
+ </div>
+ <div class="pk-tabs--container">
+
+ <div id="inspect-infos" role="tabpanel" tabindex="0" aria-labelledby="tab-info">
+<ul>
+<li><span class="parameter">{% trans "Title" %}{% trans ":" %}</span> {{ formdef.name|default:"-" }}</li>
+<li><span class="parameter">{% trans "Description" %}{% trans ":" %}</span> {{ formdef.description|default:"-"|safe }}</li>
+<li><span class="parameter">{% trans "Keywords" %}{% trans ":" %}</span> {{ formdef.keywords|default:"-" }}</li>
+<li><span class="parameter">{% trans "Category" %}{% trans ":" %}</span> {% if formdef.category %}<a href="{{ formdef.category.get_admin_url }}">{{ formdef.category.name }}</a>{% else %}-{% endif %}</li>
+</ul>
+</div>
+
+<div id="inspect-workflow" role="tabpanel" tabindex="0" aria-labelledby="tab-workflow" hidden>
+<ul>
+<li><span class="parameter">{% trans "Workflow" %}{% trans ":" %}</span> <a href="{{ formdef.workflow.get_admin_url }}">{{ formdef.workflow.name }}</a></li>
+<li><span class="parameter">{% trans "Options" %}{% trans ":" %}</span> {% if not workflow_options %}-{% else %}<ul>
+  {% for label, value in workflow_options.items %}
+  <li>{{ label }} → {{ value|safe|default:"-" }}</li>
+  {% endfor %}
+</ul>{% endif %}</li>
+{% for wf_role_id, wf_role_label, role_label in workflow_roles %}
+<li><span class="parameter">{{ wf_role_label }}{% trans ":" %}</span> {{ role_label|default:"-" }}</li>
+{% endfor %}
+<li><span class="parameter">{% trans "User Roles" %}{% trans ":" %}</span> {{ view.get_roles_label_and_auth_context|default:"-" }}</li>
+<li><span class="parameter">{% trans "Backoffice Submission Role" %}{% trans ":" %}</span> {{ backoffice_submission_roles|default:"-" }}</li>
+</ul>
+</div>
+
+<div id="inspect-options" role="tabpanel" tabindex="0" aria-labelledby="tab-options" hidden>
+<ul>
+ <li><span class="parameter">{% trans "Confirmation Page" %}{% trans ":" %}</span> {{ formdef.confirmation|yesno }}</li>
+ <li><span class="parameter">{% trans "Limit to one form" %}{% trans ":" %}</span> {{ formdef.only_allow_one|yesno }}</li>
+ {% if formdef.roles %}
+ <li><span class="parameter">{% trans "Display to unlogged users" %}{% trans ":" %}</span> {{ formdef.always_advertise|yesno }}</li>
+ {% endif %}
+ <li><span class="parameter">{% trans "Include button to download all files" %}{% trans ":" %}</span> {{ formdef.include_download_all_button|yesno }}</li>
+ <li><span class="parameter">{% trans "Skip from per user view" %}{% trans ":" %}</span> {{ formdef.skip_from_360_view|yesno }}</li>
+ <li><span class="parameter">{% trans "Tracking codes" %}{% trans ":" %}</span> {{ formdef.enable_tracking_codes|yesno }}</li>
+ {% if formdef.enable_tracking_codes %}
+ <li><span class="parameter">{% trans "Fields to check after entering the tracking code" %}{% trans ":" %}</span> {{ tracking_code_verify_fields_labels|default:"-" }}</li>
+ {% endif %}
+ <li><span class="parameter">{% trans "Lifespan of drafts (in days)" %}{% trans ":" %}</span> {{ formdef.drafts_lifespan|default_if_none:_('default value') }}</li>
+ <li><span class="parameter">{% trans "Templates" %}</span>
+   <ul>
+     <li><span class="parameter">{% trans "Digest" %}{% trans ":" %}</span> {{ formdef.default_digest_template|default:"-" }}</li>
+     <li><span class="parameter">{% trans "Lateral Block" %}{% trans ":" %}</span> {{ formdef.lateral_template|default:"-" }}</li>
+     <li><span class="parameter">{% trans "Submission Lateral Block" %}{% trans ":" %}</span> {{ formdef.submission_lateral_template|default:"-" }}</li>
+   </ul>
+ </li>
+ <li><span class="parameter">{% trans "Disable access to form" %}{% trans ":" %}</span> {{ formdef.disabled|yesno }}</li>
+ <li><span class="parameter">{% trans "Redirection when disabled" %}{% trans ":" %}</span> {{ formdef.disabled_redirection|default:"-" }}</li>
+ <li><span class="parameter">{% trans "Publication date" %}{% trans ":" %}</span> {{ formdef.publication_date|default:"-" }}</li>
+ <li><span class="parameter">{% trans "Expiration date" %}{% trans ":" %}</span> {{ formdef.expiration_date|default:"-" }}</li>
+
+</ul>
+</div>
+
+<div id="inspect-fields" role="tabpanel" tabindex="0" aria-labelledby="tab-fields" hidden>
+<div class="pk-information"><p>
+{% blocktrans count page_count=formdef.page_count %}{{ page_count }} page{% plural %}{{ page_count }} pages{% endblocktrans %},
+{% blocktrans count fields_count=formdef.fields|count %}{{ fields_count }} field{% plural %}{{ fields_count }} fields.{% endblocktrans %}
+</p></div>
+{% for field in formdef.fields %}
+<div class="inspect-field inspect-field--{{ field.key }} {% if forloop.first %}inspect-field--first{% endif %}" data-field-id="{{ field.id }}">
+<h4><a href="fields/{{ field.id }}/">{{ field.ellipsized_label }}</a> <span class="inspect-field-type">- {{ field.get_type_label }}</span></h4>
+{{ field.get_parameters_view|safe }}
+</div>
+{% endfor %}
+</div>
+</div>
+
+</div>
+{% endblock %}
-- 
2.36.1

