add live field conditions (#436)

This commit is contained in:
Frédéric Péters 2018-07-28 20:55:26 +02:00
parent 668a04f96a
commit 1e852f8d88
14 changed files with 275 additions and 54 deletions

View File

@ -984,7 +984,7 @@ def test_form_new_field(pub):
# check it's in the preview
resp = app.get('/backoffice/forms/1/')
assert '<h3>baz</h3>' in resp.body
assert '<h3 data-field-id="1">baz</h3>' in resp.body
def test_form_delete_field(pub):
create_role()

View File

@ -4123,7 +4123,7 @@ def test_backoffice_formdata_named_wscall(http_requests, pub):
app = login(get_app(pub))
resp = app.get('/backoffice/submission/test/')
assert '<p class="comment-field ">XbarY</p>' in resp.body
assert '<p data-field-id="7" class="comment-field ">XbarY</p>' in resp.body
# check with publisher variable in named webservice call
if not pub.site_options.has_section('variables'):
@ -4138,13 +4138,13 @@ def test_backoffice_formdata_named_wscall(http_requests, pub):
wscall.store()
resp = app.get('/backoffice/submission/test/')
assert '<p class="comment-field ">XbarY</p>' in resp.body
assert '<p data-field-id="7" class="comment-field ">XbarY</p>' in resp.body
# django-templated URL
wscall.request = {'url': '{{ example_url }}json'}
wscall.store()
resp = app.get('/backoffice/submission/test/')
assert '<p class="comment-field ">XbarY</p>' in resp.body
assert '<p data-field-id="7" class="comment-field ">XbarY</p>' in resp.body
# webservice call in django template
formdef.fields = [
@ -4153,7 +4153,7 @@ def test_backoffice_formdata_named_wscall(http_requests, pub):
formdef.store()
formdef.data_class().wipe()
resp = app.get('/backoffice/submission/test/')
assert '<p class="comment-field ">dja-bar-ngo</p>' in resp.body
assert '<p data-field-id="7" class="comment-field ">dja-bar-ngo</p>' in resp.body
def test_backoffice_session_var(pub):
open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w').write('''[options]
@ -4177,7 +4177,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 '<p class="comment-field ">XbarY</p>' in resp.body
assert '<p data-field-id="7" class="comment-field ">XbarY</p>' in resp.body
# django template
formdef.fields = [
@ -4188,7 +4188,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 '<p class="comment-field ">django</p>' in resp.body
assert '<p data-field-id="7" class="comment-field ">django</p>' in resp.body
def test_backoffice_display_message(pub):
user = create_user(pub)

View File

@ -133,65 +133,65 @@ def test_title():
field = fields.TitleField(label='Foobar')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<h3>Foobar</h3>' in str(form.render())
assert '<h3 data-field-id="None">Foobar</h3>' in str(form.render())
field = fields.TitleField(label='Foobar', extra_css_class='test')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<h3 class="test">Foobar</h3>' in str(form.render())
assert '<h3 data-field-id="None" class="test">Foobar</h3>' in str(form.render())
def test_subtitle():
field = fields.SubtitleField(label='Foobar')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<h4>Foobar</h4>' in str(form.render())
assert '<h4 data-field-id="None">Foobar</h4>' in str(form.render())
field = fields.SubtitleField(label='Foobar', extra_css_class='test')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<h4 class="test">Foobar</h4>' in str(form.render())
assert '<h4 data-field-id="None" class="test">Foobar</h4>' in str(form.render())
def test_comment():
field = fields.CommentField(label='Foobar')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<p class="comment-field ">Foobar</p>' in str(form.render())
assert '<p data-field-id="None" class="comment-field ">Foobar</p>' in str(form.render())
field = fields.CommentField(label='Foo\n\nBar\n\nBaz')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<p>Foo</p>\n<p>Bar</p>\n<p>Baz</p>' in str(form.render())
assert '<div class="comment-field "' in str(form.render())
assert '<div data-field-id="None" class="comment-field "' in str(form.render())
# test for variable substitution
pub.substitutions.feed(MockSubstitutionVariables())
field = fields.CommentField(label='{{ bar }}')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<p class="comment-field ">Foobar</p>' in str(form.render())
assert '<p data-field-id="None" class="comment-field ">Foobar</p>' in str(form.render())
field = fields.CommentField(label='[bar]')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<p class="comment-field ">Foobar</p>' in str(form.render())
assert '<p data-field-id="None" class="comment-field ">Foobar</p>' 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 '<p class="comment-field ">1 &lt; 3</p>' in str(form.render())
assert '<p data-field-id="None" class="comment-field ">1 &lt; 3</p>' in str(form.render())
field = fields.CommentField(label='[foo]')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<p class="comment-field ">1 &lt; 3</p>' in str(form.render())
assert '<p data-field-id="None" class="comment-field ">1 &lt; 3</p>' in str(form.render())
# test for html content
field = fields.CommentField(label='<p>Foobar</p>')
form = Form(use_tokens=False)
field.add_to_form(form)
assert '<p>Foobar</p>' in str(form.render())
assert '<div class="comment-field "' in str(form.render())
assert '<div data-field-id="None" class="comment-field "' in str(form.render())
assert field.unhtmled_label == 'Foobar'
field = fields.CommentField(label='<p>Foobar&eacute;</p>')

View File

@ -1086,15 +1086,15 @@ def test_form_titles(pub):
formdef.data_class().wipe()
resp = get_app(pub).get('/test/')
assert not '<h3>1st page/h3>' in resp.body
assert '<h4>subtitle of 1st page</h4>' in resp.body
assert not '<h3 data-field-id="0">1st page/h3>' in resp.body
assert '<h4 data-field-id="5">subtitle of 1st page</h4>' in resp.body
resp.form['f1'] = 'foo'
resp = resp.form.submit('submit')
assert '<h3>title of second page</h3>' in resp.body
assert '<h3 data-field-id="6">title of second page</h3>' in resp.body
resp = resp.form.submit('submit') # -> validation page
assert '<h3>1st page</h3>' in resp.body
assert '<h4>subtitle of 1st page</h4>' in resp.body
assert '<h3>title of second page</h3>' in resp.body
assert '<h4 data-field-id="5">subtitle of 1st page</h4>' in resp.body
assert '<h3 data-field-id="6">title of second page</h3>' in resp.body
resp.form['f3'] = 'foo'
resp = resp.form.submit('submit').follow() # -> submit
assert '<h3>1st page</h3>' in resp.body
@ -5075,3 +5075,105 @@ def test_field_condition(pub):
resp = resp.follow()
assert '<span class="label">Bar</span>' in resp.body
assert '<span class="label">Foo</span>' 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 '<span class="label">Bar</span>' in resp.body
assert '<span class="label">Foo</span>' 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 '<span class="label">Bar</span>' in resp.body
assert '<span class="label">Foo</span>' 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')

View File

@ -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']

View File

@ -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('<h3%s>%s</h3>' % (extra_css_class, self.label))))
extra_attributes += ' class="%s"' % self.extra_css_class
widget = HtmlWidget(htmltext('<h3%s>%s</h3>' % (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('<h4%s>%s</h4>' % (extra_css_class, self.label))))
extra_attributes += ' class="%s"' % self.extra_css_class
widget = HtmlWidget(htmltext('<h4%s>%s</h4>' % (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 = '<p>' + re.sub('\n\n+', '</p>\n<p>', self.label) + '</p>'
@ -579,9 +604,10 @@ class CommentField(Field):
enclosing_tag = 'div'
break
form.widgets.append(HtmlWidget(
htmltext('<%s %s>%s</%s>' % (enclosing_tag, class_attribute,
label, enclosing_tag))))
widget = HtmlWidget(htmltext('<%s %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"

View File

@ -536,14 +536,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]

View File

@ -22,7 +22,7 @@ from .root import FormPage
class PreviewFormPage(FormPage):
_q_exports = ['', 'tempfile']
_q_exports = ['', 'tempfile', 'live']
def check_role(self):
pass

View File

@ -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,29 @@ 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 in live_condition_fields:
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 +710,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 +723,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) and 'f%s' % field.id in form._names:
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 +1008,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

View File

@ -418,9 +418,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

View File

@ -47,6 +47,7 @@ $(function() {
$(base_widget).find('input[type=hidden]').val(token);
$(base_widget).find('input[type=file]').hide();
$(base_widget).find('.use-file-from-fargo').hide();
$(base_widget).find('input[type=file]').trigger('wcs:change');
}
document.fargo_close_dialog();
}

View File

@ -29,6 +29,7 @@ $(function() {
$(this).hide();
$(base_widget).find('.use-file-from-fargo').hide();
$(base_widget).addClass('has-file').removeClass('has-no-file');
$(this).trigger('wcs:change');
},
progress: function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
@ -41,6 +42,7 @@ $(function() {
$(base_widget).find('input[type=file]').show();
$(base_widget).find('.use-file-from-fargo').show();
$(base_widget).removeClass('has-file').addClass('has-no-file');
$(base_widget).find('input[type=file]').trigger('wcs:change');
return false;
});
$(this).find('a.change').click(function() {

View File

@ -67,4 +67,28 @@ $(function() {
}
return true;
});
var live_evaluation = null;
$('form div[data-live-source] input, form div[data-live-source] select, form div[data-live-source] textarea').on('change keyup paste wcs:change', 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();
}
});
}
});
});
});

View File

@ -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 %}