forms: evaluate conditions during typing (#22102)

This commit is contained in:
Frédéric Péters 2018-03-24 13:00:18 +01:00
parent 602597edd9
commit 62267edcfa
7 changed files with 111 additions and 15 deletions

View File

@ -2015,6 +2015,23 @@ def test_validate_expression(pub):
resp = get_app(pub).get('/api/validate-expression?expression==hello[\'plop\']')
assert resp.json == {'klass': None, 'msg': ''}
def test_validate_condition(pub):
resp = get_app(pub).get('/api/validate-condition?type=python&value_python=hello')
assert resp.json == {'klass': None, 'msg': ''}
resp = get_app(pub).get('/api/validate-condition?type=python&value_python=~2')
assert resp.json == {'klass': None, 'msg': ''}
resp = get_app(pub).get('/api/validate-condition?type=python&value_python=hello -')
assert resp.json['klass'] == 'error'
assert resp.json['msg'].startswith('syntax error')
resp = get_app(pub).get('/api/validate-condition?type=django&value_django=~2')
assert resp.json['klass'] == 'error'
assert resp.json['msg'].startswith('syntax error')
resp = get_app(pub).get('/api/validate-condition?type=unknown&value_unknown=2')
assert resp.json['klass'] == 'error'
assert resp.json['msg'] == 'unknown condition type'
@pytest.fixture(params=['sql', 'pickle'])
def no_request_pub(request):
pub = create_temporary_pub(sql_mode=bool(request.param == 'sql'))

View File

@ -27,9 +27,10 @@ from qommon import misc
from qommon.evalutils import make_datetime
from qommon.errors import (AccessForbiddenError, QueryError, TraversalError,
UnknownNameIdAccessForbiddenError)
from qommon.form import ValidationError, ComputedExpressionWidget
from qommon.form import ComputedExpressionWidget, ConditionWidget
from wcs.categories import Category
from wcs.conditions import Condition, ValidationError
from wcs.formdef import FormDef
from wcs.roles import Role, logged_users_role
from wcs.forms.common import FormStatusPage
@ -668,7 +669,8 @@ class ApiTrackingCodeDirectory(Directory):
class ApiDirectory(Directory):
_q_exports = ['forms', 'roles', ('reverse-geocoding', 'reverse_geocoding'),
'formdefs', 'categories', 'user', 'users', 'code',
('validate-expression', 'validate_expression'),]
('validate-expression', 'validate_expression'),
('validate-condition', 'validate_condition')]
forms = ApiFormsDirectory()
formdefs = ApiFormdefsDirectory()
@ -720,6 +722,19 @@ class ApiDirectory(Directory):
hint['msg'] = _('Make sure you want a Python expression, not a simple template string.')
return json.dumps(hint)
def validate_condition(self):
get_response().set_content_type('application/json')
condition = {}
condition['type'] = get_request().form.get('type') or ''
condition['value'] = get_request().form.get('value_' + condition['type'])
hint = {'klass': None, 'msg': ''}
try:
Condition(condition).validate()
except ValidationError as e:
hint['klass'] = 'error'
hint['msg'] = str(e)
return json.dumps(hint)
def _q_traverse(self, path):
get_request().is_json_marker = True
return super(ApiDirectory, self)._q_traverse(path)

View File

@ -17,9 +17,13 @@
import sys
from quixote import get_publisher
from django.template import Context, Template
from django.template import Context, Template, TemplateSyntaxError
from qommon import get_logger
from qommon import _, get_logger
class ValidationError(ValueError):
pass
class Condition(object):
@ -60,3 +64,22 @@ class Condition(object):
template = Template('{%% load qommon %%}{%% if %s %%}OK{%% endif %%}' % self.value)
context = Context(local_variables)
return template.render(context) == 'OK'
def validate(self):
if not self.type or not self.value:
return
if not hasattr(self, 'validate_' + self.type):
raise ValidationError(_('unknown condition type'))
return getattr(self, 'validate_' + self.type)()
def validate_python(self):
try:
compile(self.value, '<string>', 'eval')
except (SyntaxError, TypeError) as e:
raise ValidationError(_('syntax error: %s') % e)
def validate_django(self):
try:
Template('{%% load qommon %%}{%% if %s %%}OK{%% endif %%}' % self.value)
except TemplateSyntaxError as e:
raise ValidationError(_('syntax error: %s') % e)

View File

@ -66,6 +66,7 @@ from django.utils.safestring import mark_safe
from .template import render as render_template, Template, TemplateError
from wcs.portfolio import has_portfolio
from wcs.conditions import Condition, ValidationError
from qommon import _, ngettext
import misc
@ -195,9 +196,6 @@ class SubmitWidget(quixote.form.widget.SubmitWidget):
return htmltag('button', name=self.name, value=value, **self.attrs) + \
self.label + htmltext('</button>')
class ValidationError(ValueError):
pass
class RadiobuttonsWidget(quixote.form.RadiobuttonsWidget):
def __init__(self, name, value=None, **kwargs):
@ -454,10 +452,14 @@ class CompositeWidget(quixote.form.CompositeWidget):
r = TemplateIO(html=True)
r += htmltext('<tr>\n')
for widget in self.get_widgets():
extra_attributes = ''
classnames = '%s widget' % widget.__class__.__name__
if hasattr(widget, 'extra_css_class') and widget.extra_css_class:
classnames += ' ' + widget.extra_css_class
r += htmltext('<td><div class="%s"><div class="content">' % classnames)
if hasattr(widget, 'content_extra_attributes'):
extra_attributes = ' '.join(['%s=%s' % x for x in widget.content_extra_attributes.items()])
r += htmltext('<td><div class="%s"><div class="content" %s>' % (
classnames, extra_attributes))
r += widget.render_content()
r += htmltext('</div></div></td>')
r += htmltext('</tr>\n')
@ -2305,6 +2307,11 @@ class ConditionWidget(CompositeWidget):
attrs={'data-dynamic-display-child-of': '%s$type' % self.name,
'data-dynamic-display-value': 'python'})
@property
def content_extra_attributes(self):
validation_url = get_publisher().get_root_url() + 'api/validate-condition'
return {'data-validation-url': validation_url}
def _parse(self, request):
self.value = {}
if not self.get('type') or self.get('type') == 'none':
@ -2315,11 +2322,10 @@ class ConditionWidget(CompositeWidget):
if self.value['type']:
self.value['value'] = self.get('value_%s' % self.value['type'])
if self.value['type'] == 'python' and self.value.get('value'):
try:
compile(self.value.get('value'), '<string>', 'eval')
except (SyntaxError, TypeError), e:
self.set_error(_('invalid expression: %s') % e)
try:
Condition(self.value).validate()
except ValidationError as e:
self.set_error(str(e))
if not self.value['value']:
self.value = None

View File

@ -1302,8 +1302,11 @@ div.ComputedExpressionWidget div.content input:focus {
border-left-width: 3ex;
}
div.ComputedExpressionWidget.hint-error div.content input {
div.content.hint-error input[type],
.hint-error div.content input[type] {
border-color: red;
outline: none;
box-shadow: 0 0 0 1px red;
}
div.ComputedExpressionWidget.hint-warning div.content input {

View File

@ -63,6 +63,34 @@ $(function() {
return false;
});
$('div[data-validation-url]').each(function(idx, elem) {
var $widget = $(this);
var widget_name = $widget.find('select').attr('name');
var prefix = widget_name.replace(/\$[a-z]*/, '') + '$';
$(this).find('input, select').on('change focus keyup', function() {
clearTimeout($widget.validation_timeout_id);
$widget.validation_timeout_id = setTimeout(function() {
var data = Object();
$widget.find('select, input').each(function(idx, elem) {
data[$(elem).attr('name').replace(prefix, '')] = $(elem).val();
});
$.ajax({
url: $widget.data('validation-url'),
data: data,
dataType: 'json',
success: function(data) {
$widget.removeClass('hint-warning');
$widget.removeClass('hint-error');
if (data.klass) {
$widget.addClass('hint-' + data.klass);
}
$widget.prop('title', data.msg);
}
})}, 250);
return false;
});
});
/* keep title/slug in sync */
$('body').delegate('input[data-slug-sync]', 'keyup change paste',
function() {

View File

@ -19,7 +19,11 @@
{{widget.rendered_title}}
{% endblock %}
{% block widget-content %}
<div class="content {{widget.content.content_extra_css_class}}">
<div class="content {{widget.content.content_extra_css_class}}"
{% for attr, value in widget.content_extra_attributes.items %}
{{attr}}="{{value}}"
{% endfor %}
>
{% block widget-error %}{{widget.rendered_error}}{% endblock %}
{% block widget-control %}{{widget.render_content|safe}}{% endblock %}
{% block widget-hint %}{{widget.rendered_hint}}{% endblock %}