From d21ff54bb6aeb42de0f0c793927851b2869096a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Tue, 18 Oct 2016 14:18:20 +0200 Subject: [PATCH] admin: add live validation (+ hints) to computed expression widgets (#13650) --- tests/test_api.py | 12 +++++++++++ tests/test_widgets.py | 2 +- wcs/api.py | 19 +++++++++++++++++- wcs/qommon/form.py | 39 ++++++++++++++++++++++++------------ wcs/qommon/static/css/dc2/admin.css | 13 ++++++++++++ wcs/qommon/static/js/qommon.admin.js | 24 ++++++++++++++++++++++ 6 files changed, 94 insertions(+), 15 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index a452bb7..a3fe6e4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1485,6 +1485,18 @@ def test_tracking_code(pub): formdata.remove_self() resp = get_app(pub).get('/api/code/%s' % code.id, status=404) +def test_validate_expression(pub): + resp = get_app(pub).get('/api/validate-expression?expression=hello') + assert resp.json == {'klass': None, 'msg': ''} + resp = get_app(pub).get('/api/validate-expression?expression=[hello]') + assert resp.json == {'klass': None, 'msg': ''} + resp = get_app(pub).get('/api/validate-expression?expression==[hello') + assert resp.json['klass'] == 'error' + assert resp.json['msg'].startswith('syntax error') + resp = get_app(pub).get('/api/validate-expression?expression==[hello]') + assert resp.json['klass'] == 'warning' + assert resp.json['msg'].startswith('Make sure you want a Python expression,') + @pytest.fixture(params=['sql', 'pickle']) def no_request_pub(request): pub = create_temporary_pub(sql_mode=bool(request.param == 'sql')) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 6cfa879..9ebebcd 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -21,7 +21,7 @@ def setup_module(module): global pub, req pub = create_temporary_pub() - req = HTTPRequest(None, {}) + req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'}) req.language = None pub._set_request(req) diff --git a/wcs/api.py b/wcs/api.py index 7fc25eb..fb0a80d 100644 --- a/wcs/api.py +++ b/wcs/api.py @@ -16,6 +16,7 @@ import hashlib import json +import re import time import urllib2 import sys @@ -25,6 +26,7 @@ from quixote.directory import Directory from qommon import misc from qommon.errors import (AccessForbiddenError, QueryError, TraversalError, UnknownNameIdAccessForbiddenError) +from qommon.form import ValidationError, ComputedExpressionWidget from wcs.categories import Category from wcs.data_sources import NamedDataSource @@ -611,7 +613,7 @@ class ApiDataSourcesDirectory(Directory): class ApiDirectory(Directory): _q_exports = ['forms', 'roles', ('reverse-geocoding', 'reverse_geocoding'), 'formdefs', 'categories', 'user', 'users', 'code', - 'datasources'] + 'datasources', ('validate-expression', 'validate_expression'),] forms = ApiFormsDirectory() formdefs = ApiFormdefsDirectory() @@ -648,6 +650,21 @@ class ApiDirectory(Directory): get_response().set_content_type('application/json') return json.dumps({'data': list_roles}) + def validate_expression(self): + get_response().set_content_type('application/json') + expression = get_request().form.get('expression') + hint = {'klass': None, 'msg': ''} + try: + ComputedExpressionWidget.validate(expression) + except ValidationError as e: + hint['klass'] = 'error' + hint['msg'] = str(e) + else: + if expression and re.match(r'^=.*\[\w+\]', expression): + hint['klass'] = 'warning' + hint['msg'] = _('Make sure you want a Python expression, not a simple template string.') + return json.dumps(hint) + def _q_traverse(self, path): get_request().is_json_marker = True return super(ApiDirectory, self)._q_traverse(path) diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py index 86db7f1..4f866ac 100644 --- a/wcs/qommon/form.py +++ b/wcs/qommon/form.py @@ -200,6 +200,10 @@ def submit_render_content(self): SubmitWidget.render_content = submit_render_content +class ValidationError(ValueError): + pass + + class RadiobuttonsWidget(quixote.form.RadiobuttonsWidget): def __init__(self, name, value=None, **kwargs): self.options_with_attributes = kwargs.pop('options_with_attributes', None) @@ -2303,6 +2307,11 @@ class ComputedExpressionWidget(StringWidget): '''StringWidget that checks the entered value is a correct workflow expression.''' + def render_content(self): + validation_url = get_publisher().get_root_url() + 'api/validate-expression' + self.attrs['data-validation-url'] = validation_url + return StringWidget.render_content(self) + @classmethod def validate_ezt(cls, template): processor = ezt.Template(compress_whitespace=False) @@ -2325,20 +2334,24 @@ class ComputedExpressionWidget(StringWidget): parts.append(_('at line %(line)d and column %(column)d') % { 'line': e.line+1, 'column': e.column+1}) - raise ValueError(_('error in template (%s)') % ' '.join(parts)) + raise ValidationError(_('error in template (%s)') % ' '.join(parts)) + + @classmethod + def validate(cls, expression): + if not expression: + return + if expression.startswith('=') and len(expression) > 1: + try: + compile(expression[1:], '', 'eval') + except SyntaxError as e: + raise ValidationError(_('syntax error (%s)') % e) + else: + cls.validate_ezt(expression) def _parse(self, request): StringWidget._parse(self, request) if self.value: - if self.value.startswith('='): - # python expression - try: - compile(self.value[1:], '', 'eval') - except SyntaxError as e: - self.set_error(_('syntax error (%s)') % e) - else: - # ezt expression - try: - self.validate_ezt(self.value) - except ValueError as e: - self.set_error(str(e)) + try: + self.validate(self.value) + except ValidationError as e: + self.set_error(str(e)) diff --git a/wcs/qommon/static/css/dc2/admin.css b/wcs/qommon/static/css/dc2/admin.css index 85bf529..8d2f1cc 100644 --- a/wcs/qommon/static/css/dc2/admin.css +++ b/wcs/qommon/static/css/dc2/admin.css @@ -1253,6 +1253,19 @@ div.ComputedExpressionWidget div.content input:focus { border-left-width: 3ex; } +div.ComputedExpressionWidget.hint-error div.content input { + border-color: red; +} + +div.ComputedExpressionWidget.hint-warning div.content input { + border-color: orange; +} + +div.ComputedExpressionWidget.hint-error div.content span.hint-text, +div.ComputedExpressionWidget.hint-warning div.content span.hint-text { + display: block; +} + div.ComputedExpressionWidget div.content::before { font-family: FontAwesome; content: "\f1b2"; diff --git a/wcs/qommon/static/js/qommon.admin.js b/wcs/qommon/static/js/qommon.admin.js index 1057b45..4f0cc7e 100644 --- a/wcs/qommon/static/js/qommon.admin.js +++ b/wcs/qommon/static/js/qommon.admin.js @@ -37,6 +37,30 @@ $(function() { }); }); + /* hints on the computed expression widget */ + var validation_timeout_id = 0; + $('input[data-validation-url]').on('change focus keyup', function() { + var val = $(this).val(); + var $widget = $(this).parents('.ComputedExpressionWidget'); + var validation_url = $(this).data('validation-url'); + clearTimeout(validation_timeout_id); + validation_timeout_id = setTimeout(function() { + $.ajax({ + url: validation_url, + data: {expression: val}, + 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 sidebar sticky */ if ($('#sidebar').length) { var $window = $(window); -- 2.9.3