From 7b20a28803f5cb06e1d5b2625739ef2bab75a86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Sat, 28 Jul 2018 20:55:26 +0200 Subject: [PATCH 4/4] add live field conditions (#436) --- tests/test_admin_pages.py | 2 +- tests/test_backoffice_pages.py | 12 +- tests/test_fields.py | 22 ++-- tests/test_form_pages.py | 112 +++++++++++++++++- wcs/backoffice/submission.py | 2 +- wcs/fields.py | 63 +++++++--- wcs/formdef.py | 11 +- wcs/forms/root.py | 64 +++++++++- wcs/qommon/form.py | 7 +- wcs/qommon/static/js/qommon.forms.js | 24 ++++ wcs/qommon/templates/qommon/forms/widget.html | 6 +- 11 files changed, 272 insertions(+), 53 deletions(-) diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py index 92d2d8f1..dbe9bd9b 100644 --- a/tests/test_admin_pages.py +++ b/tests/test_admin_pages.py @@ -984,7 +984,7 @@ def test_form_new_field(pub): # check it's in the preview resp = app.get('/backoffice/forms/1/') - assert '

baz

' in resp.body + assert '

baz

' in resp.body def test_form_delete_field(pub): create_role() diff --git a/tests/test_backoffice_pages.py b/tests/test_backoffice_pages.py index 61a3f270..022f911a 100644 --- a/tests/test_backoffice_pages.py +++ b/tests/test_backoffice_pages.py @@ -4120,7 +4120,7 @@ def test_backoffice_formdata_named_wscall(http_requests, pub): app = login(get_app(pub)) resp = app.get('/backoffice/submission/test/') - assert '

XbarY

' in resp.body + assert '

XbarY

' in resp.body # check with publisher variable in named webservice call if not pub.site_options.has_section('variables'): @@ -4135,13 +4135,13 @@ def test_backoffice_formdata_named_wscall(http_requests, pub): wscall.store() resp = app.get('/backoffice/submission/test/') - assert '

XbarY

' in resp.body + assert '

XbarY

' in resp.body # django-templated URL wscall.request = {'url': '{{ example_url }}json'} wscall.store() resp = app.get('/backoffice/submission/test/') - assert '

XbarY

' in resp.body + assert '

XbarY

' in resp.body # webservice call in django template formdef.fields = [ @@ -4150,7 +4150,7 @@ def test_backoffice_formdata_named_wscall(http_requests, pub): formdef.store() formdef.data_class().wipe() resp = app.get('/backoffice/submission/test/') - assert '

dja-bar-ngo

' in resp.body + assert '

dja-bar-ngo

' in resp.body def test_backoffice_session_var(pub): open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w').write('''[options] @@ -4174,7 +4174,7 @@ query_string_allowed_vars = foo,bar resp = app.get('/backoffice/submission/test/?session_var_foo=bar') assert resp.location.endswith('/backoffice/submission/test/') resp = resp.follow() - assert '

XbarY

' in resp.body + assert '

XbarY

' in resp.body # django template formdef.fields = [ @@ -4185,7 +4185,7 @@ query_string_allowed_vars = foo,bar resp = app.get('/backoffice/submission/test/?session_var_foo=jang') assert resp.location.endswith('/backoffice/submission/test/') resp = resp.follow() - assert '

django

' in resp.body + assert '

django

' in resp.body def test_backoffice_display_message(pub): user = create_user(pub) diff --git a/tests/test_fields.py b/tests/test_fields.py index 951c47c4..1f4ff884 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -133,65 +133,65 @@ def test_title(): field = fields.TitleField(label='Foobar') form = Form(use_tokens=False) field.add_to_form(form) - assert '

Foobar

' in str(form.render()) + assert '

Foobar

' in str(form.render()) field = fields.TitleField(label='Foobar', extra_css_class='test') form = Form(use_tokens=False) field.add_to_form(form) - assert '

Foobar

' in str(form.render()) + assert '

Foobar

' in str(form.render()) def test_subtitle(): field = fields.SubtitleField(label='Foobar') form = Form(use_tokens=False) field.add_to_form(form) - assert '

Foobar

' in str(form.render()) + assert '

Foobar

' in str(form.render()) field = fields.SubtitleField(label='Foobar', extra_css_class='test') form = Form(use_tokens=False) field.add_to_form(form) - assert '

Foobar

' in str(form.render()) + assert '

Foobar

' in str(form.render()) def test_comment(): field = fields.CommentField(label='Foobar') form = Form(use_tokens=False) field.add_to_form(form) - assert '

Foobar

' in str(form.render()) + assert '

Foobar

' in str(form.render()) field = fields.CommentField(label='Foo\n\nBar\n\nBaz') form = Form(use_tokens=False) field.add_to_form(form) assert '

Foo

\n

Bar

\n

Baz

' in str(form.render()) - assert '
Foobar

' in str(form.render()) + assert '

Foobar

' in str(form.render()) field = fields.CommentField(label='[bar]') form = Form(use_tokens=False) field.add_to_form(form) - assert '

Foobar

' in str(form.render()) + assert '

Foobar

' in str(form.render()) # test for proper escaping of substitution variables field = fields.CommentField(label='{{ foo }}') form = Form(use_tokens=False) field.add_to_form(form) - assert '

1 < 3

' in str(form.render()) + assert '

1 < 3

' in str(form.render()) field = fields.CommentField(label='[foo]') form = Form(use_tokens=False) field.add_to_form(form) - assert '

1 < 3

' in str(form.render()) + assert '

1 < 3

' in str(form.render()) # test for html content field = fields.CommentField(label='

Foobar

') form = Form(use_tokens=False) field.add_to_form(form) assert '

Foobar

' in str(form.render()) - assert '
Foobaré

') diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py index dcd2798c..6e528a51 100644 --- a/tests/test_form_pages.py +++ b/tests/test_form_pages.py @@ -1084,15 +1084,15 @@ def test_form_titles(pub): formdef.data_class().wipe() resp = get_app(pub).get('/test/') - assert not '

1st page/h3>' in resp.body - assert '

subtitle of 1st page

' in resp.body + assert not '

1st page/h3>' in resp.body + assert '

subtitle of 1st page

' in resp.body resp.form['f1'] = 'foo' resp = resp.form.submit('submit') - assert '

title of second page

' in resp.body + assert '

title of second page

' in resp.body resp = resp.form.submit('submit') # -> validation page assert '

1st page

' in resp.body - assert '

subtitle of 1st page

' in resp.body - assert '

title of second page

' in resp.body + assert '

subtitle of 1st page

' in resp.body + assert '

title of second page

' in resp.body resp.form['f3'] = 'foo' resp = resp.form.submit('submit').follow() # -> submit assert '

1st page

' in resp.body @@ -5030,3 +5030,105 @@ def test_field_condition(pub): resp = resp.follow() assert 'Bar' in resp.body assert 'Foo' not in resp.body + +def test_field_live_condition(pub): + FormDef.wipe() + formdef = FormDef() + formdef.name = 'Foo' + formdef.fields = [ + fields.StringField(type='string', id='1', label='Bar', size='40', + required=True, varname='bar'), + fields.StringField(type='string', id='2', label='Foo', size='40', + required=True, varname='foo', + condition={'type': 'django', 'value': 'form_var_bar == "bye"'}), + ] + formdef.store() + + app = get_app(pub) + resp = app.get('/foo/') + assert 'f1' in resp.form.fields + assert 'f2' in resp.form.fields + assert resp.html.find('div', {'data-field-id': '1'}).attrs['data-live-source'] == 'true' + assert resp.html.find('div', {'data-field-id': '2'}).attrs.get('style') == 'display: none' + resp.form['f1'] = 'hello' + live_resp = app.post('/foo/live', params=resp.form.submit_fields()) + assert live_resp.json['result']['1']['visible'] + assert not live_resp.json['result']['2']['visible'] + resp.form['f1'] = 'bye' + live_resp = app.post('/foo/live', params=resp.form.submit_fields()) + assert live_resp.json['result']['1']['visible'] + assert live_resp.json['result']['2']['visible'] + resp.form['f1'] = 'hello' + resp = resp.form.submit('submit') + assert 'Check values then click submit.' in resp.body + assert 'name="f1"' in resp.body + assert 'name="f2"' not in resp.body + resp = resp.form.submit('submit') + resp = resp.follow() + assert 'Bar' in resp.body + assert 'Foo' not in resp.body + + resp = get_app(pub).get('/foo/') + assert 'f1' in resp.form.fields + assert 'f2' in resp.form.fields + resp.form['f1'] = 'bye' + resp = resp.form.submit('submit') + assert 'There were errors' in resp.body + assert resp.html.find('div', {'data-field-id': '2'}).attrs.get('style') is None + resp.form['f2'] = 'bye' + resp = resp.form.submit('submit') + assert 'Check values then click submit.' in resp.body + assert 'name="f1"' in resp.body + assert 'name="f2"' in resp.body + resp = resp.form.submit('submit') + resp = resp.follow() + assert 'Bar' in resp.body + assert 'Foo' in resp.body + +def test_field_live_condition_multipages(pub): + FormDef.wipe() + formdef = FormDef() + formdef.name = 'Foo' + formdef.fields = [ + fields.PageField(id='0', label='2nd page', type='page'), + fields.StringField(type='string', id='1', label='Bar', size='40', + required=True, varname='bar'), + fields.StringField(type='string', id='2', label='Foo', size='40', + required=True, varname='foo', + condition={'type': 'django', 'value': 'form_var_bar == "bye"'}), + fields.PageField(id='3', label='1st page', type='page'), + fields.StringField(type='string', id='4', label='Baz', size='40', + required=True, varname='baz'), + ] + formdef.store() + + app = get_app(pub) + resp = app.get('/foo/') + assert 'f1' in resp.form.fields + assert 'f2' in resp.form.fields + assert resp.html.find('div', {'data-field-id': '1'}).attrs['data-live-source'] == 'true' + assert resp.html.find('div', {'data-field-id': '2'}).attrs.get('style') == 'display: none' + resp.form['f1'] = 'hello' + live_resp = app.post('/foo/live', params=resp.form.submit_fields()) + assert live_resp.json['result']['1']['visible'] + assert not live_resp.json['result']['2']['visible'] + resp.form['f1'] = 'bye' + live_resp = app.post('/foo/live', params=resp.form.submit_fields()) + assert live_resp.json['result']['1']['visible'] + assert live_resp.json['result']['2']['visible'] + resp.form['f1'] = 'bye' + resp.form['f2'] = 'bye' + resp = resp.form.submit('submit') + resp = resp.form.submit('previous') + assert resp.html.find('div', {'data-field-id': '2'}).attrs.get('style') is None + live_resp = app.post('/foo/live', params=resp.form.submit_fields()) + assert live_resp.json['result']['1']['visible'] + assert live_resp.json['result']['2']['visible'] + resp = resp.form.submit('submit') + resp.form['f4'] = 'plop' + resp = resp.form.submit('submit') + assert 'Check values then click submit.' in resp.body + assert 'name="f1"' in resp.body + assert 'name="f2"' in resp.body + assert 'name="f4"' in resp.body + resp = resp.form.submit('submit') diff --git a/wcs/backoffice/submission.py b/wcs/backoffice/submission.py index 44b8df12..d4dfbb56 100644 --- a/wcs/backoffice/submission.py +++ b/wcs/backoffice/submission.py @@ -76,7 +76,7 @@ class RemoveDraftDirectory(Directory): class FormFillPage(PublicFormFillPage): _q_exports = ['', 'tempfile', 'autosave', 'code', - ('remove', 'remove_draft')] + ('remove', 'remove_draft'), 'live'] filling_templates = ['wcs/formdata_filling.html'] validation_templates = ['wcs/formdata_validation.html'] diff --git a/wcs/fields.py b/wcs/fields.py index 26b5ca6e..4afa7a52 100644 --- a/wcs/fields.py +++ b/wcs/fields.py @@ -381,6 +381,29 @@ class Field(object): except RuntimeError: return True + def get_condition_varnames(self): + return re.findall(r'\bform[_\.]var[_\.]([a-zA-Z0-9_]+?)(?:_raw|\b)', self.condition['value']) + + def has_live_conditions(self, formdef): + varnames = self.get_condition_varnames() + if not varnames: + return False + field_position = formdef.fields.index(self) + # rewind to field page + for field_position in range(field_position, -1, -1): + if formdef.fields[field_position].type == 'page': + break + else: + field_position = -1 # form with no page + # start from there + for field in formdef.fields[field_position+1:]: + if field.type == 'page': + # stop at next page + break + if field.varname in varnames: + return True + return False + class WidgetField(Field): hint = None @@ -413,6 +436,7 @@ class WidgetField(Field): widget.extra_css_class = self.extra_css_class if self.varname: widget.div_id = 'var_%s' % self.varname + return widget def perform_more_widget_changes(self, form, kwargs, edit = True): pass @@ -519,12 +543,12 @@ class TitleField(Field): description = N_('Title') def add_to_form(self, form, value = None): + extra_attributes = ' data-field-id="%s"' % self.id if self.extra_css_class: - extra_css_class = ' class="%s"' % self.extra_css_class - else: - extra_css_class = '' - form.widgets.append(HtmlWidget( - htmltext('%s' % (extra_css_class, self.label)))) + extra_attributes += ' class="%s"' % self.extra_css_class + widget = HtmlWidget(htmltext('%s' % (extra_attributes, self.label))) + form.widgets.append(widget) + return widget add_to_view_form = add_to_form def fill_admin_form(self, form): @@ -547,12 +571,12 @@ class SubtitleField(TitleField): description = N_('Subtitle') def add_to_form(self, form, value = None): + extra_attributes = ' data-field-id="%s"' % self.id if self.extra_css_class: - extra_css_class = ' class="%s"' % self.extra_css_class - else: - extra_css_class = '' - form.widgets.append(HtmlWidget( - htmltext('%s' % (extra_css_class, self.label)))) + extra_attributes += ' class="%s"' % self.extra_css_class + widget = HtmlWidget(htmltext('%s' % (extra_attributes, self.label))) + form.widgets.append(widget) + return widget add_to_view_form = add_to_form register_field_class(SubtitleField) @@ -563,7 +587,8 @@ class CommentField(Field): description = N_('Comment') def add_to_form(self, form, value = None): - class_attribute = 'class="comment-field %s"' % (self.extra_css_class or '') + tag_attributes = 'data-field-id="%s" class="comment-field %s"' % ( + self.id, self.extra_css_class or '') if '\n\n' in self.label: label = '

' + re.sub('\n\n+', '

\n

', self.label) + '

' @@ -579,9 +604,10 @@ class CommentField(Field): enclosing_tag = 'div' break - form.widgets.append(HtmlWidget( - htmltext('<%s %s>%s' % (enclosing_tag, class_attribute, - label, enclosing_tag)))) + widget = HtmlWidget(htmltext('<%s %s>%s' % ( + enclosing_tag, tag_attributes, label, enclosing_tag))) + form.widgets.append(widget) + return widget def add_to_view_form(self, *args): pass @@ -1050,7 +1076,7 @@ class DateField(WidgetField): def add_to_form(self, form, value=None): if value and type(value) is not str: value = self.convert_value_to_str(value) - WidgetField.add_to_form(self, form, value=value) + return WidgetField.add_to_form(self, form, value=value) def add_to_view_form(self, form, value = None): value = localstrftime(value) @@ -1531,8 +1557,11 @@ class PageCondition(Condition): # create variables with values currently being evaluated, not yet # available in the formdata. from formdata import get_dict_with_varnames - live_data = get_dict_with_varnames(formdef.fields, dict_vars) - form_live_data = dict(('form_' + x, y) for x, y in live_data.items()) + live_data = {} + form_live_data = {} + if dict_vars is not None: + live_data = get_dict_with_varnames(formdef.fields, dict_vars) + form_live_data = dict(('form_' + x, y) for x, y in live_data.items()) # 1) feed the form_var_* variables in the global substitution system, # they will shadow formdata context variables with their new "live" diff --git a/wcs/formdef.py b/wcs/formdef.py index b0fe1bf0..2e9cfc86 100644 --- a/wcs/formdef.py +++ b/wcs/formdef.py @@ -532,14 +532,19 @@ class FormDef(StorableObject): continue if not on_page: continue - if not field.is_visible(form_data, self): - continue + visible = field.is_visible(form_data, self) + if not visible: + if not field.has_live_conditions(self): + # no live conditions so field can be skipped + continue if type(displayed_fields) is list: displayed_fields.append(field) value = None if form_data: value = form_data.get(field.id) - field.add_to_form(form, value) + widget = field.add_to_form(form, value) + widget.is_hidden = not(visible) + widget.field = field def get_page(self, page_no): return [x for x in self.fields if x.type == 'page'][page_no] diff --git a/wcs/forms/root.py b/wcs/forms/root.py index 491cb3dd..830d6a93 100644 --- a/wcs/forms/root.py +++ b/wcs/forms/root.py @@ -16,6 +16,7 @@ import copy import json +import re import time from StringIO import StringIO import sys @@ -171,7 +172,7 @@ class TrackingCodesDirectory(Directory): class FormPage(Directory, FormTemplateMixin): _q_exports = ['', 'tempfile', 'schema', 'tryauth', - 'auth', 'qrcode', 'autosave', 'code', 'removedraft'] + 'auth', 'qrcode', 'autosave', 'code', 'removedraft', 'live'] filling_templates = ['wcs/front/formdata_filling.html', 'wcs/formdata_filling.html'] validation_templates = ['wcs/front/formdata_validation.html', 'wcs/formdata_validation.html'] @@ -378,16 +379,30 @@ class FormPage(Directory, FormTemplateMixin): if not one: req.form = {} + live_condition_fields = {} for field in displayed_fields: if field.prefill: # always set additional attributes as they will be used for # "live prefill", regardless of existing data. form.get_widget('f%s' % field.id).prefill_attributes = field.get_prefill_attributes() + if field.condition: + field.varnames = field.get_condition_varnames() + for varname in field.varnames: + if not varname in live_condition_fields: + live_condition_fields[varname] = [] + live_condition_fields[varname].append(field) + + for field in displayed_fields: + if field.varname not in live_condition_fields: + continue + form.get_widget('f%s' % field.id).live_condition_source = True self.html_top(self.formdef.name) form.add_hidden('step', '0') form.add_hidden('page', self.pages.index(page)) + if page: + form.add_hidden('page_id', page.id) form.add_submit('cancel', _('Cancel'), css_class = 'cancel') if self.formdef.enable_tracking_codes and not self.edit_mode: @@ -696,7 +711,8 @@ class FormPage(Directory, FormTemplateMixin): self.feed_current_data(magictoken) - form = self.create_form(page=page) + submitted_fields = [] + form = self.create_form(page=page, displayed_fields=submitted_fields) form.add_submit('previous') if self.formdef.enable_tracking_codes: form.add_submit('removedraft') @@ -708,13 +724,18 @@ class FormPage(Directory, FormTemplateMixin): if self.formdef.enable_tracking_codes and form.get_submit() == 'removedraft': return self.removedraft() + form_data = session.get_by_magictoken(magictoken, {}) + data = self.formdef.get_data(form) + form_data.update(data) + if self.formdef.enable_tracking_codes and form.get_submit() == 'savedraft': - form_data = session.get_by_magictoken(magictoken, {}) - data = self.formdef.get_data(form) - form_data.update(data) filled = self.save_draft(form_data, page_no) return redirect(filled.get_url().rstrip('/')) + for field in submitted_fields: + if not field.is_visible(form_data, self.formdef): + del form._names['f%s' % field.id] + page_error_messages = [] if form.get_submit() == 'submit' and page: post_conditions = page.post_conditions or [] @@ -988,6 +1009,39 @@ class FormPage(Directory, FormTemplateMixin): pass return None + def live(self): + get_request().ignore_session = True + # live evaluation of fields + get_response().set_content_type('application/json') + def result_error(reason): + return json.dumps({'result': 'error', 'reason': reason}) + + session = get_session() + if not session: + return result_error('missing session') + + formdata = self.get_transient_formdata() + get_publisher().substitutions.feed(formdata) + + page_id = get_request().form.get('page_id') + if page_id: + for field in self.formdef.fields: + if str(field.id) == page_id: + page = field + break + else: + page = None + + displayed_fields = [] + form = self.create_form(page=page, displayed_fields=displayed_fields) + formdata.data.update(self.formdef.get_data(form)) + + result = {} + for field in displayed_fields: + result[field.id] = {'visible': field.is_visible(formdata.data, self.formdef)} + + return json.dumps({'result': result}) + def submitted(self, form, existing_formdata = None): if existing_formdata: # modifying filled = existing_formdata diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py index 1e2adcba..fe1a573b 100644 --- a/wcs/qommon/form.py +++ b/wcs/qommon/form.py @@ -417,9 +417,10 @@ class HtmlWidget(object): return self.render_content() def render_content(self): - if self.title: - return htmltext(self.title) - return htmltext(self.string) + content = self.title or self.string or '' + if getattr(self, 'is_hidden', False): + content = htmltext(str(content).replace('>', ' style="display: none">', 1)) + return htmltext(content) def has_error(self, request): return False diff --git a/wcs/qommon/static/js/qommon.forms.js b/wcs/qommon/static/js/qommon.forms.js index cb2213ce..eee6e5bd 100644 --- a/wcs/qommon/static/js/qommon.forms.js +++ b/wcs/qommon/static/js/qommon.forms.js @@ -67,4 +67,28 @@ $(function() { } return true; }); + var live_evaluation = null; + $('form div[data-live-source] input, form div[data-live-source] select').on('change keyup paste', function() { + var new_data = $(this).parents('form').serialize(); + if (live_evaluation) { + live_evaluation.abort(); + } + live_evaluation = $.ajax({ + type: 'POST', + url: window.location.pathname + 'live', + dataType: 'json', + data: new_data, + headers: {'accept': 'application/json'}, + success: function(json) { + $.each(json.result, function(key, value) { + var $widget = $('[data-field-id="' + key + '"]'); + if (value.visible) { + $widget.show(); + } else { + $widget.hide(); + } + }); + } + }); + }); }); diff --git a/wcs/qommon/templates/qommon/forms/widget.html b/wcs/qommon/templates/qommon/forms/widget.html index e205ad49..35e1ea97 100644 --- a/wcs/qommon/templates/qommon/forms/widget.html +++ b/wcs/qommon/templates/qommon/forms/widget.html @@ -4,6 +4,8 @@ {% if widget.get_error %}widget-with-error{% endif %} {% if widget.is_required %}widget-required{% else %}widget-optional{% endif %} {% if widget.is_prefilled %}widget-prefilled{% endif %}" + {% if widget.is_hidden %}style="display: none"{% endif %} + {% if widget.field %}data-field-id="{{ widget.field.id }}"{% endif %} {% if widget.div_id %}id="{{widget.div_id}}" data-valuecontainerid="form_{{widget.name}}"{% endif %} {% for attr in widget.prefill_attributes %} data-{{attr}}="{{widget.prefill_attributes|get:attr}}" @@ -13,7 +15,9 @@ {% endif %} {% if "data-dynamic-display-value" in widget.attrs %} data-dynamic-display-value="{{widget.attrs|get:"data-dynamic-display-value"}}" - {% endif %}> + {% endif %} + {% if widget.live_condition_source %}data-live-source="true"{% endif %} + > {% block widget-title %} {{widget.rendered_title}} {% endblock %} -- 2.18.0