code serveur pour la validation à la volée (#76632) #241

Merged
fpeters merged 1 commits from wip/76632-error-live-check into main 2023-04-18 14:28:39 +02:00
10 changed files with 346 additions and 66 deletions

View File

@ -2356,7 +2356,7 @@ def test_backoffice_wfedit_and_data_source_with_field_info(pub):
resp = resp.follow()
assert len(rsps.calls) == 1
assert '?xxx=FOO%20BAR%2030' in rsps.calls[-1].request.url
assert 'invalid value' not in resp
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 0

Il y a toute une série de changements de ce type dans les tests, c'est parce que l'HTML incorpore désormais un <template> avec dedans une classe "error", qui se trouvait donc matchée.

Il y a toute une série de changements de ce type dans les tests, c'est parce que l'HTML incorpore désormais un `<template>` avec dedans une classe "error", qui se trouvait donc matchée.
assert resp.form['f3'].value == 'EE'
resp.form['f3'].value = 'DD'
resp = resp.form.submit('submit')

View File

@ -537,7 +537,7 @@ def test_form_string_field_submit(pub):
page = get_app(pub).get('/test/')
formdef.data_class().wipe()
next_page = page.forms[0].submit('submit') # but the field is required
assert next_page.pyquery('div.error').text() == 'required field'
assert next_page.pyquery('#form_error_f0').text() == 'required field'

Ou pour la même raison, plutôt que viser le div.error, viser le div du champ précis.

Ou pour la même raison, plutôt que viser le div.error, viser le div du champ précis.
next_page.forms[0]['f0'] = 'foobar'
next_page = next_page.forms[0].submit('submit')
assert 'Check values then click submit.' in next_page.text
@ -569,7 +569,7 @@ def test_form_items_submit(pub):
page = get_app(pub).get('/test/')
assert 'List of items' in page.text
next_page = page.forms[0].submit('submit') # but the field is required
assert next_page.pyquery('div.error').text() == 'required field'
assert next_page.pyquery('#form_error_f0').text() == 'required field'
next_page.forms[0]['f0$element0'].checked = True
next_page.forms[0]['f0$element1'].checked = True
next_page = next_page.forms[0].submit('submit')
@ -591,14 +591,14 @@ def test_form_items_submit(pub):
page = get_app(pub).get('/test/')
page.forms[0]['f0$element0'].checked = True
page = page.forms[0].submit('submit')
assert page.pyquery('div.error').text() == 'You must select at least 2 answers.'
assert page.pyquery('#form_error_f0').text() == 'You must select at least 2 answers.'
page.forms[0]['f0$element1'].checked = True
page.forms[0]['f0$element2'].checked = True
page.forms[0]['f0$element3'].checked = True
page.forms[0]['f0$element4'].checked = True
page.forms[0]['f0$element5'].checked = True
page = page.forms[0].submit('submit')
assert page.pyquery('div.error').text() == 'You must select at most 5 answers.'
assert page.pyquery('#form_error_f0').text() == 'You must select at most 5 answers.'
page.forms[0]['f0$element5'].checked = False
page = next_page.forms[0].submit('submit').follow()
assert 'The form has been recorded' in page.text
@ -623,7 +623,7 @@ def test_form_items_autocomplete(pub):
resp = get_app(pub).get('/test/')
assert 'select2.min.js' in resp.text
resp = resp.forms[0].submit('submit') # but the field is required
assert resp.pyquery('div.error').text() == 'required field'
assert resp.pyquery('#form_error_f0').text() == 'required field'
resp.forms[0]['f0[]'].select_multiple(['Foo', 'Bar'])
resp = resp.forms[0].submit('submit')
assert resp.pyquery('[name="f0[]"] option[selected]').text() == 'Foo Bar'
@ -646,11 +646,11 @@ def test_form_items_autocomplete(pub):
resp = get_app(pub).get('/test/')
resp.forms[0]['f0[]'].select_multiple(['Foo', 'Bar'])
resp = resp.forms[0].submit('submit')
assert resp.pyquery('div.error').text() == 'You must select at least 3 choices.'
assert resp.pyquery('#form_error_f0').text() == 'You must select at least 3 choices.'
assert resp.forms[0]['f0[]'].value == ['Foo', 'Bar']
resp.forms[0]['f0[]'].select_multiple(['Foo', 'Bar', 'Three', 'Four', 'Five'])
resp = resp.forms[0].submit('submit')
assert resp.pyquery('div.error').text() == 'You must select at most 4 choices.'
assert resp.pyquery('#form_error_f0').text() == 'You must select at most 4 choices.'
assert resp.forms[0]['f0[]'].value == ['Foo', 'Bar', 'Three', 'Four', 'Five']
resp.forms[0]['f0[]'].select_multiple(['Foo', 'Bar', 'Three'])
resp = resp.forms[0].submit('submit') # -> validation
@ -2746,7 +2746,7 @@ def test_form_draft_save_on_error_page(pub):
tracking_code = get_displayed_tracking_code(resp)
resp.forms[0]['f1'] = 'plop'
resp = resp.forms[0].submit('submit')
assert resp.pyquery('div.error').text() == 'required field'
assert resp.pyquery('#form_error_f2').text() == 'required field'
resp = login(get_app(pub), username='foo', password='foo').get('/')
resp.forms[0]['code'] = tracking_code

View File

@ -98,7 +98,7 @@ def test_block_required(pub):
resp = app.get(formdef.get_url())
resp = resp.form.submit('submit') # -> error page
assert 'There were errors processing the form' in resp
assert resp.text.count('required field') == 1
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 1
resp.form['f1$element0$f123'] = 'foo'
resp.form['f1$element0$f234'] = 'bar'
resp = resp.form.submit('submit') # -> validation page
@ -108,7 +108,7 @@ def test_block_required(pub):
resp.form['f1$element0$f123'] = 'foo'
resp = resp.form.submit('submit') # -> error page
assert 'There were errors processing the form' in resp
assert resp.text.count('required field') == 1
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 1
resp.form['f1$element0$f234'] = 'bar'
resp = resp.form.submit('submit') # -> validation page
assert 'Check values then click submit.' in resp.text
@ -135,7 +135,7 @@ def test_block_required(pub):
resp = app.get(formdef.get_url())
resp = resp.form.submit('submit') # -> error page
assert 'There were errors processing the form' in resp
assert resp.text.count('required field') == 1
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 1
resp.form['f1$element0$f234'] = 'bar'
resp = resp.form.submit('submit') # -> validation page
assert 'Check values then click submit.' in resp.text
@ -174,7 +174,7 @@ def test_block_required_previous_page(pub):
resp = app.get(formdef.get_url())
resp = resp.form.submit('submit') # -> error page
assert 'There were errors processing the form' in resp
assert resp.text.count('required field') == 1
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 1
resp.form['f1$element0$f123'] = 'foo'
resp.form['f1$element0$f234'] = 'bar'
if multipage:
@ -185,7 +185,7 @@ def test_block_required_previous_page(pub):
if multipage:
resp = resp.form.submit('previous') # -> 2nd page
resp = resp.form.submit('previous') # -> 1st page
assert resp.text.count('required field') == 0
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 0
assert resp.form['f1$element0$f123'].value == 'foo'
assert resp.form['f1$element0$f234'].value == 'bar'
@ -222,7 +222,7 @@ def test_block_required_previous_page(pub):
if multipage:
resp = resp.form.submit('previous') # -> 2nd page
resp = resp.form.submit('previous') # -> 1st page
assert resp.text.count('required field') == 0
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 0
assert resp.form['f1$element0$f123'].value == 'foo'
assert resp.form['f1$element0$f234'].value == 'bar'
assert resp.form['f1$element1$f123'].value == 'foo2'
@ -429,7 +429,7 @@ def test_block_string_prefill(pub):
resp = resp.form.submit('submit') # -> 2nd page
assert not resp.pyquery('#form_error_f3') # not marked as error
assert not resp.pyquery('#form_error_f4') # ...
assert '>required field<' not in resp
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 0
assert resp.form['f3$element0$f123'].value == 'Hello World'
assert resp.form['f4$element0$f123'].value == ''
resp.form['f4$element0$f123'] = 'plop'
@ -506,11 +506,11 @@ def test_block_prefill_and_required(pub):
app = get_app(pub)
resp = app.get(formdef.get_url())
assert '>required field<' not in resp
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 0
assert resp.form['f2$element0$f123'].value == 'World'
resp.form['f3$element0$f123'] = 'Hello'
resp = resp.form.submit('submit') # -> same page, error displyed
assert resp.text.count('>required field<') == 1
assert len(resp.pyquery('.error:not(#form_error_fieldname)')) == 1
resp.form['f3$element0$f234'].checked = True
resp = resp.form.submit('submit') # -> validation page
resp = resp.form.submit('submit') # -> end page

View File

@ -426,9 +426,9 @@ def test_form_validation_message(pub):
resp = get_app(pub).get(formdef.get_url(language='en'))
resp.form['f1'] = 'test'
resp = resp.form.submit('submit') # -> validation error
assert resp.pyquery('.error').text() == 'validation error'
assert resp.pyquery('#form_error_f1').text() == 'validation error'
resp = get_app(pub).get(formdef.get_url(language='fr'))
resp.form['f1'] = 'test'
resp = resp.form.submit('submit') # -> validation error
assert resp.pyquery('.error').text() == 'erreur de validation'
assert resp.pyquery('#form_error_f1').text() == 'erreur de validation'

View File

@ -220,7 +220,7 @@ def test_live_field_condition_on_required_field(pub):
assert live_resp.json['result']['1']['visible']
assert live_resp.json['result']['2']['visible']
resp = resp.form.submit('submit')
assert resp.pyquery('div.error').text() == 'required field'
assert resp.pyquery('#form_error_f2').text() == 'required field'
assert 'HELLO' not in resp.text
@ -1972,3 +1972,81 @@ def test_comment_from_card_field(pub):
resp.form['f1'] = '2'
live_resp = app.post('/test/live', params=resp.form.submit_fields())
assert live_resp.json['result']['3']['content'] == '<p>Xbar {{ form_var_foo }}Ybar plopZ</p>'
def test_field_live_validation(pub):

C'est ici les vrais nouveaux tests.

C'est ici les vrais nouveaux tests.
FormDef.wipe()
formdef = FormDef()
formdef.name = 'Foo'
formdef.fields = [
fields.StringField(type='string', id='1', label='Bar', required=True, validation={'type': 'digits'}),
]
formdef.store()
app = get_app(pub)
resp = app.get('/foo/')
url = resp.pyquery('form[data-live-validation-url]').attr('data-live-validation-url')
assert url
live_resp = get_app(pub).post(url, params=resp.form.submit_fields())
assert live_resp.json == {'err': 2, 'msg': 'missing session'}
live_resp = app.post(url, params=resp.form.submit_fields())
assert live_resp.json == {'err': 2, 'msg': 'missing ?field parameter'}
live_resp = app.post(url + '?field=xxx', params=resp.form.submit_fields())
assert live_resp.json == {'err': 2, 'msg': 'unknown field'}
live_resp = app.post(url + '?field=xxx__yyy', params=resp.form.submit_fields())
assert live_resp.json == {'err': 2, 'msg': 'invalid ?field parameter'}
resp.form['f1'] = '1234'
live_resp = app.post(url + '?field=f1', params=resp.form.submit_fields())
assert live_resp.json == {'err': 0}
resp.form['f1'] = ''
live_resp = app.post(url + '?field=f1', params=resp.form.submit_fields())
assert live_resp.json == {'err': 1, 'msg': 'required field', 'valueMissing': True}
resp.form['f1'] = 'abc'
live_resp = app.post(url + '?field=f1', params=resp.form.submit_fields())
assert live_resp.json == {'err': 1, 'msg': 'Only digits are allowed', 'badInput': True}
def test_block_field_live_validation(pub):
BlockDef.wipe()
block = BlockDef()
block.name = 'foobar'
block.fields = [
fields.StringField(type='string', id='1', label='Bar', required=True, validation={'type': 'digits'}),
]
block.store()
FormDef.wipe()
formdef = FormDef()
formdef.name = 'Foo'
formdef.fields = [
fields.BlockField(id='2', label='test', type='block:foobar'),
]
formdef.store()
app = get_app(pub)
resp = app.get('/foo/')
url = resp.pyquery('form[data-live-validation-url]').attr('data-live-validation-url')
assert url
resp.form['f2$element0$f1'] = '1234'
live_resp = app.post(url + '?field=f2__element0__f1', params=resp.form.submit_fields())
assert live_resp.json == {'err': 0}
resp.form['f2$element0$f1'] = ''
live_resp = app.post(url + '?field=f2__element0__f1', params=resp.form.submit_fields())
assert live_resp.json == {'err': 1, 'msg': 'required field', 'valueMissing': True}
resp.form['f2$element0$f1'] = 'abc'
live_resp = app.post(url + '?field=f2__element0__f1', params=resp.form.submit_fields())
assert live_resp.json == {'err': 1, 'msg': 'Only digits are allowed', 'badInput': True}
live_resp = app.post(url + '?field=f2__element0__fX', params=resp.form.submit_fields())
assert live_resp.json == {'err': 2, 'msg': 'unknown sub field'}

View File

@ -1433,3 +1433,15 @@ def test_emoji_button():
assert PyQuery(str(form.render()))('button').attr.name == 'submit'
assert not PyQuery(str(form.render()))('button').attr['aria-label']
assert PyQuery(str(form.render()))('button').text() == ''
def test_error_templates():
widget = TextWidget('test', value='foo', maxlength=10)
widget_html = str(widget.render())
assert 'data-supports-live-validation=' not in widget_html
widget.supports_live_validation = True
widget_html = str(widget.render())
assert 'data-supports-live-validation=' in widget_html
assert PyQuery(widget_html)('#error_test_valueMissing').text() == 'required field'
assert PyQuery(widget_html)('#error_test_tooLong').text() == 'too many characters (limit is 10)'

View File

@ -876,6 +876,7 @@ class WidgetField(Field):
prefill = {}
widget_class = None
widget_supports_live_validation = True

attribut pour déterminer qu'un champ gère la validation à la volée, ça permet derrière d'exclure les champs dépréciés type tableaux, exclus du périmètre.

attribut pour déterminer qu'un champ gère la validation à la volée, ça permet derrière d'exclure les champs dépréciés type tableaux, exclus du périmètre.
def add_to_form(self, form, value=None):
kwargs = {'required': self.required, 'render_br': False}
@ -892,6 +893,7 @@ class WidgetField(Field):
form.add(self.widget_class, 'f%s' % self.id, title=self.label, hint=hint, **kwargs)
widget = form.get_widget('f%s' % self.id)
widget.field = self
widget.supports_live_validation = self.widget_supports_live_validation
if self.extra_css_class:
if hasattr(widget, 'extra_css_class') and widget.extra_css_class:
widget.extra_css_class = '%s %s' % (widget.extra_css_class, self.extra_css_class)
@ -1639,6 +1641,7 @@ class FileField(WidgetField):
key = 'file'
description = _('File Upload')
allow_complex = True
widget_supports_live_validation = False

Les champs fichier aussi sont exclus mais c'est parce que la validation a déjà lieu lors du chargement du fichier.

Les champs fichier aussi sont exclus mais c'est parce que la validation a déjà lieu lors du chargement du fichier.
document_type = None
max_file_size = None
@ -3150,6 +3153,7 @@ class TableField(WidgetField):
columns = None
widget_class = TableWidget
widget_supports_live_validation = False
def __init__(self, **kwargs):
self.rows = []
@ -3371,6 +3375,7 @@ class TableRowsField(WidgetField):
columns = None
widget_class = TableListRowsWidget
widget_supports_live_validation = False
def __init__(self, **kwargs):
self.columns = []
@ -3632,6 +3637,7 @@ class RankedItemsField(WidgetField):
items = []
randomize_items = False
widget_class = RankedItemsWidget
widget_supports_live_validation = False
anonymise = False
def perform_more_widget_changes(self, form, kwargs, edit=True):
@ -3904,7 +3910,9 @@ class BlockField(WidgetField):
self.block
except KeyError:
raise MissingBlockFieldError(self.type[6:])
return super().add_to_form(form, value=value)
widget = super().add_to_form(form, value=value)
widget.supports_live_validation = False
return widget
def fill_admin_form(self, form):
super().fill_admin_form(form)

View File

@ -46,7 +46,15 @@ from wcs.workflows import ContentSnapshotPart, WorkflowBackofficeFieldsFormDef,
from ..qommon import _, emails, errors, get_cfg, misc, ngettext, template
from ..qommon.admin.emails import EmailsDirectory
from ..qommon.form import CheckboxWidget, EmailWidget, Form, HiddenErrorWidget, HtmlWidget, StringWidget
from ..qommon.form import (
CheckboxWidget,
EmailWidget,
ErrorMessage,
Form,
HiddenErrorWidget,
HtmlWidget,
StringWidget,
)
from ..qommon.template import TemplateError
from ..qommon.template_utils import render_block_to_string
@ -270,6 +278,7 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin):
'code',
'removedraft',
'live',
('live-validation', 'live_validation'),
('go-to-backoffice', 'go_to_backoffice'),
]
@ -941,6 +950,16 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin):
def create_form(self, *args, **kwargs):
form = self.formdef.create_form(*args, **kwargs)
form.attrs['data-live-url'] = self.formdef.get_url(language=get_publisher().current_language) + 'live'

C'est la présence de cet attribut qui détermine la prise en charge, ou pas, de la validation à la volée. (un moment j'ai hésité à partager data-live-url pour également y faire la validation, mais non).

C'est la présence de cet attribut qui détermine la prise en charge, ou pas, de la validation à la volée. (un moment j'ai hésité à partager data-live-url pour également y faire la validation, mais non).
form.attrs['data-live-validation-url'] = (
self.formdef.get_url(language=get_publisher().current_language) + 'live-validation'
)
form.widgets.append(
HtmlWidget(
'''<template id="form_error_tpl">
<div id="form_error_fieldname" class="error"></div>
</template>'''
)
)
return form
def create_view_form(self, *args, **kwargs):
@ -1668,6 +1687,51 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin):
return result_error('form deserialization failed: %s' % e)
return FormStatusPage.live_process_fields(form, formdata, displayed_fields)
def live_validation(self):
# live validation of field values
get_request().ignore_session = True
get_response().set_content_type('application/json')
def result_error(reason):
return json.dumps({'err': 2, 'msg': reason})
session = get_session()
if not (session and session.id):
return result_error('missing session')
field_ref = get_request().form.get('field')
if not field_ref:
return result_error('missing ?field parameter')

En paramètre c'est ?field={{widget.get_name_for_id}} qui est une méthode qui fournit le nom avec les $ (invalides dans les id) remplacés par des __, ici on fait la conversion inverse.

En paramètre c'est ?field={{widget.get_name_for_id}} qui est une méthode qui fournit le nom avec les $ (invalides dans les id) remplacés par des `__`, ici on fait la conversion inverse.
parts = field_ref.split('__')

S'il y a un morceau, c'est un champ normal, s'il y a trois morceaux, c'est un champ d'un bloc (f0$element0$f1).

S'il y a un morceau, c'est un champ normal, s'il y a trois morceaux, c'est un champ d'un bloc (f0$element0$f1).
if len(parts) not in (1, 3):
return result_error('invalid ?field parameter')
for field in self.formdef.fields:
if 'f%s' % field.id == parts[0]:
break
else:
return result_error('unknown field')
if len(parts) == 3: # block field
for subfield in field.block.fields or []:
if 'f%s' % subfield.id == parts[2]:
break
else:
return result_error('unknown sub field')
field = subfield
field.id = field_ref[1:].replace('__', '$')
form = Form()
widget = field.add_to_form(form)

On crée un formulaire avec uniquement le champ qui nous intéresse, c'est pour ça que juste au-dessus pour le champ d'un bloc on modifie l'id, pour faire comme si c'était un champ direct.

On gagne du temps ici à ne pas créer de formdata temporaire etc. c'est possible parce que les validations sur les champs sont limitées aux données de celui-ci.

On crée un formulaire avec uniquement le champ qui nous intéresse, c'est pour ça que juste au-dessus pour le champ d'un bloc on modifie l'id, pour faire comme si c'était un champ direct. On gagne du temps ici à ne pas créer de formdata temporaire etc. c'est possible parce que les validations sur les champs sont limitées aux données de celui-ci.
error = widget.get_error()
if error:
resp = {'err': 1, 'msg': str(error)}
if hasattr(widget, 'error_code'):
error_message = ErrorMessage(widget.error_code, '')

On répond ici avec un format qui mime ce que l'API de validation javascript propose.

On répond ici avec un format qui mime ce que l'API de validation javascript propose.
resp[error_message.camel_code()] = True
return json.dumps(resp)
else:
return json.dumps({'err': 0})
def clean_submission_context(self):
get_publisher().substitutions.unfeed(lambda x: x.__class__.__name__ == 'ConditionVars')
get_publisher().substitutions.unfeed(lambda x: isinstance(x, FormData))

View File

@ -178,6 +178,41 @@ def widget_get_name_for_id(self):
return self.name.replace('$', '__')
class ErrorMessage:
# map error code and readable message
def __init__(self, code, message):
self.code = code
self.message = message
def camel_code(self):
return ''.join(x.lower() if i == 0 else x.capitalize() for i, x in enumerate(self.code.split('_')))

C'est pour faire value_missing -> valueMissing, pour correspondre à l'API javascript.

C'est pour faire value_missing -> valueMissing, pour correspondre à l'API javascript.
def widget_set_error_code(self, error_code):
self.set_error(getattr(self, 'get_%s_message' % error_code)())
self.error_code = error_code
def widget_set_error(self, error):
self.error = error
if self.error == Widget.REQUIRED_ERROR:
self.error_code = 'value_missing'
else:
self.error_code = 'bad_input'
def widget_get_error_messages(self):
for code in self.get_error_message_codes():
yield ErrorMessage(code, getattr(self, 'get_%s_message' % code)())
def widget_get_error_message_codes(self):
# always add codes that may be generated by javascript validation API
yield 'value_missing'
yield 'bad_input'
yield 'type_mismatch'
Review

Il y a typeMismatch qui est généré en sortie d'un champ date dont le contenu est partiel (type j'ai juste mis le jour) et en regardant https://developer.mozilla.org/en-US/docs/Web/API/ValidityState j'ai vu badInput et j'ai décidé de remplacer ce que j'avais nommé "invalid_value" par "bad_input".

Il y a typeMismatch qui est généré en sortie d'un champ date dont le contenu est partiel (type j'ai juste mis le jour) et en regardant https://developer.mozilla.org/en-US/docs/Web/API/ValidityState j'ai vu badInput et j'ai décidé de remplacer ce que j'avais nommé "invalid_value" par "bad_input".
Widget.render = render
Widget.cleanup = None
Widget.render_error = render_error
@ -186,6 +221,13 @@ Widget.render_title = render_title
Widget.is_prefilled = is_prefilled
Widget.render_widget_content = render_widget_content
Widget.get_name_for_id = widget_get_name_for_id
Widget.get_error_messages = widget_get_error_messages
Widget.get_error_message_codes = widget_get_error_message_codes
Widget.get_value_missing_message = lambda x: Widget.REQUIRED_ERROR

Introduction de cette série de méthodes sur Widget, on est toujours à monkeypatcher ainsi la classe de base dans quixote (il y aurait #5790 pour revoir ça).

Introduction de cette série de méthodes sur Widget, on est toujours à monkeypatcher ainsi la classe de base dans quixote (il y aurait #5790 pour revoir ça).
Widget.get_bad_input_message = lambda x: _('Invalid value')
Widget.get_type_mismatch_message = lambda x: _('Invalid value')
Widget.set_error = widget_set_error
Widget.set_error_code = widget_set_error_code
def file_render_content(self):
@ -571,16 +613,25 @@ class StringWidget(QuixoteStringWidget):
if self.value:
self.value = self.value.strip()
if self.maxlength and len(self.value) > self.maxlength:
self.error = _('Too long, value must be at most %d characters.') % self.maxlength
self.set_error_code('too_long')
elif self.validation_function:
try:
self.validation_function(self.value)
except ValueError as e:
self.error = str(e)
self.set_error(str(e))
def get_too_long_message(self):
return _('Too long, value must be at most %d characters.') % self.maxlength

Pour les codes connus de l'API javascript on les fournit comme ça.

Pour les codes connus de l'API javascript on les fournit comme ça.
def get_error_message_codes(self):
yield from super().get_error_message_codes()
if self.maxlength:
yield 'too_long'
def render_content(self):
attrs = {'id': 'form_' + self.get_name_for_id()}
if self.required:
attrs['required'] = 'required'
attrs['aria-required'] = 'true'
if getattr(self, 'prefill_attributes', None) and 'autocomplete' in self.prefill_attributes:
attrs['autocomplete'] = self.prefill_attributes['autocomplete']
@ -627,12 +678,23 @@ class TextWidget(QuixoteTextWidget):
maxlength = 0
if maxlength:
if len(self.value) > maxlength:
self.error = _('too many characters (limit is %d)') % maxlength
self.set_error_code('too_long')
if use_validation_function and self.validation_function:
try:
self.validation_function(self.value)
except ValueError as e:
self.error = str(e)
self.set_error(str(e))
def get_too_long_message(self):
try:
maxlength = int(self.attrs.get('maxlength', 0))
except (TypeError, ValueError):
maxlength = 0
return _('too many characters (limit is %d)') % maxlength
def get_error_message_codes(self):
yield from super().get_error_message_codes()
yield 'too_long'
def render_content(self):
attrs = {'id': 'form_' + self.get_name_for_id()}
@ -867,7 +929,7 @@ class FileWithPreviewWidget(CompositeWidget):
try:
token = get_session().add_tempfile(self.get('file'), storage=self.storage)['token']
except UploadStorageError:
self.error = _('failed to store file (system error)')
self.set_error(_('failed to store file (system error)'))
return
request.form[self.get_widget('token').get_name()] = token
else:
@ -886,7 +948,7 @@ class FileWithPreviewWidget(CompositeWidget):
return
if self.storage and self.storage != self.storage:
self.error = _('unknown storage system (system error)')
self.set_error(_('unknown storage system (system error)'))
return
# Don't trust the browser supplied MIME type, update the Upload object
@ -922,7 +984,7 @@ class FileWithPreviewWidget(CompositeWidget):
if self.max_file_size and hasattr(self.value, 'file_size'):
# validate file size
if self.value.file_size > self.max_file_size_bytes:
self.error = _('over file size limit (%s)') % self.max_file_size
self.set_error(_('over file size limit (%s)') % self.max_file_size)
return
if self.file_type:
@ -939,7 +1001,7 @@ class FileWithPreviewWidget(CompositeWidget):
valid_file_type = True
break
if not valid_file_type:
self.error = _('invalid file type')
self.set_error(_('invalid file type'))
blacklisted_file_types = get_publisher().get_site_option('blacklisted-file-types')
if blacklisted_file_types:
@ -959,7 +1021,7 @@ class FileWithPreviewWidget(CompositeWidget):
os.path.splitext(self.value.base_filename)[-1].lower() in blacklisted_file_types
or filetype in blacklisted_file_types
):
self.error = _('forbidden file type')
self.set_error(_('forbidden file type'))
class EmailWidget(StringWidget):
@ -988,42 +1050,45 @@ class EmailWidget(StringWidget):
)
)
def get_type_mismatch_message(self):
return _('must be a valid email address')
def _parse(self, request):
StringWidget._parse(self, request)
if self.value is not None:
# basic tests first
if '@' not in self.value[1:-1]:
self.error = _('must be a valid email address')
self.set_error_code('type_mismatch')
return
if self.value[0] != '"' and ' ' in self.value:
self.error = _('must be a valid email address')
self.set_error_code('type_mismatch')
return
if self.value[0] != '"' and self.value.count('@') != 1:
self.error = _('must be a valid email address')
self.set_error_code('type_mismatch')
return
user_part, domain = self.value.rsplit('@', 1)
if not self.user_part_re.match(user_part):
self.error = _('must be a valid email address')
self.set_error_code('type_mismatch')
return
if get_cfg('emails', {}).get('check_domain_with_dns', True):
# testing for domain existence
if [x for x in domain.split('.') if not x]:
# empty parts in domain, ex: @example..net, or
# @.example.net
self.error = _('invalid address domain')
self.set_error(_('invalid address domain'))
return
domain = force_str(domain, 'utf-8', errors='ignore')
try:
domain = force_str(domain.encode('idna'))
except UnicodeError:
self.error = _('invalid address domain')
self.set_error(_('invalid address domain'))
return
if domain == 'localhost':
return
try:
dns.resolver.query(force_str(domain), 'MX')
except dns.exception.DNSException:
self.error = _('invalid address domain')
self.set_error(_('invalid address domain'))
class OptGroup:
@ -1373,13 +1438,18 @@ class WcsExtraStringWidget(StringWidget):
self.inputmode = ValidationWidget.get_html_inputmode(self.field.validation)
return super().render_content()
def get_bad_input_message(self):
validation_function_error_message = self.validation_function_error_message
if self.field and self.field.validation:
validation_function_error_message = ValidationWidget.get_validation_error_message(
self.field.validation
)
return validation_function_error_message or _('invalid value')
def _parse(self, request):
StringWidget._parse(self, request)
if self.field and self.field.validation and self.value is not None:
self.validation_function = ValidationWidget.get_validation_function(self.field.validation)
self.validation_function_error_message = ValidationWidget.get_validation_error_message(
self.field.validation
)
normalized_value = self.value
if self.field and self.value and self.field.validation:
@ -1387,7 +1457,7 @@ class WcsExtraStringWidget(StringWidget):
normalized_value = normalize(self.value)
if self.value and self.validation_function and not self.validation_function(normalized_value):
self.error = self.validation_function_error_message or _('invalid value')
self.set_error_code('bad_input')
if self.field and self.value and not self.error and self.field.validation:
self.value = normalized_value
@ -1444,6 +1514,9 @@ class DateWidget(StringWidget):
def get_format_string(cls):
return misc.date_format()
def get_type_mismatch_message(self):
return _('invalid date')
def _parse(self, request):
StringWidget._parse(self, request)
if self.value is not None:
@ -1451,19 +1524,21 @@ class DateWidget(StringWidget):
value = misc.get_as_datetime(self.value).timetuple()
self.value = strftime(self.get_format_string(), value)
except ValueError:
self.error = _('invalid date')
self.set_error_code('type_mismatch')
self.value = None
return
if value[0] < 1500 or value[0] > 2099:
self.error = _('invalid date')
self.set_error_code('type_mismatch')
self.value = None
elif self.minimum_date and value[:3] < self.minimum_date.timetuple()[:3]:
self.error = _('invalid date: date must be on or after %s') % strftime(
misc.date_format(), self.minimum_date
self.set_error(
_('invalid date: date must be on or after %s')
% strftime(misc.date_format(), self.minimum_date)
)
elif self.maximum_date and value[:3] > self.maximum_date.timetuple()[:3]:
self.error = _('invalid date; date must be on or before %s') % strftime(
misc.date_format(), self.maximum_date
self.set_error(
_('invalid date; date must be on or before %s')
% strftime(misc.date_format(), self.maximum_date)
)
def add_media(self):
@ -1522,7 +1597,7 @@ class TimeWidget(DateWidget):
value = datetime.datetime.strptime(self.value, self.get_format_string())
self.value = strftime(self.get_format_string(), value)
except ValueError:
self.error = _('invalid time')
self.set_error(_('invalid time'))
self.value = None
return
@ -1558,7 +1633,7 @@ class DateTimeWidget(CompositeWidget):
try:
misc.get_as_datetime('%s %s' % (date, time))
except ValueError:
self.error = _('invalid value')
self.set_error(_('invalid value'))
self.value = '%s %s' % (date, time)
return self.value
@ -1572,7 +1647,7 @@ class RegexStringWidget(StringWidget):
try:
re.compile(self.value)
except Exception:
self.error = _('invalid regular expression')
self.set_error(_('invalid regular expression'))
self.value = None
@ -1627,9 +1702,15 @@ class CheckboxesWidget(Widget):
if self.required and not self.value:
self.set_error(self.REQUIRED_ERROR)
if self.value and self.min_choices and len(self.value) < self.min_choices:
self.set_error(_('You must select at least %d answers.') % self.min_choices)
self.set_error_code('too_short')
if self.value and self.max_choices and len(self.value) > self.max_choices:
self.set_error(_('You must select at most %d answers.') % self.max_choices)
self.set_error_code('too_long')
def get_too_short_message(self):
return _('You must select at least %d answers.') % self.min_choices
def get_too_long_message(self):
return _('You must select at most %d answers.') % self.max_choices
def set_value(self, value):
self.value = value
@ -1657,7 +1738,7 @@ class ValidatedStringWidget(StringWidget):
if self.regex and self.value is not None:
match = re.match(self.regex, self.value)
if not match or not match.group() == self.value:
self.error = _('wrong format')
self.set_error(_('wrong format'))
class UrlWidget(ValidatedStringWidget):
@ -1668,7 +1749,7 @@ class UrlWidget(ValidatedStringWidget):
def _parse(self, request):
ValidatedStringWidget._parse(self, request)
if self.error:
self.error = _('must start with http:// or https:// and have a domain name')
self.set_error(_('must start with http:// or https:// and have a domain name'))
class VarnameWidget(ValidatedStringWidget):
@ -1680,7 +1761,7 @@ class VarnameWidget(ValidatedStringWidget):
def _parse(self, request):
ValidatedStringWidget._parse(self, request)
if self.error:
self.error = _('must only consist of letters, numbers, or underscore')
self.set_error(_('must only consist of letters, numbers, or underscore'))
# forbid id/text to be used as identifier, as they would clash against
# "native" id/text keys in datasources; forbid "status" to avoid status
# filtering being diverted to a form field.
@ -1711,7 +1792,7 @@ class SlugWidget(ValidatedStringWidget):
def _parse(self, request):
super()._parse(request)
if self.error:
self.error = _('wrong format: must only consist of letters, numbers, dashes, or underscores')
self.set_error(_('wrong format: must only consist of letters, numbers, dashes, or underscores'))
class FileSizeWidget(ValidatedStringWidget):
@ -1744,7 +1825,7 @@ class FileSizeWidget(ValidatedStringWidget):
def _parse(self, request):
ValidatedStringWidget._parse(self, request)
if self.error:
self.error = _('invalid file size')
self.set_error(_('invalid file size'))
class CaptchaWidget(CompositeWidget):
@ -1794,7 +1875,7 @@ class CaptchaWidget(CompositeWidget):
get_session().won_captcha = True
self.value = v
elif v['answer']:
self.error = _('wrong answer')
self.set_error(_('wrong answer'))
def get_title(self):
return self.question
@ -1885,7 +1966,14 @@ class WidgetList(quixote.form.widget.WidgetList):
def _parse(self, request):
super()._parse(request)
if self.max_items and self.value and len(self.value) > self.max_items:
self.set_error(_('Too many elements (maximum: %s)') % self.max_items)
self.set_error_code('too_many')
def get_too_many_message(self):
return _('Too many elements (maximum: %s)') % self.max_items
def get_error_message_codes(self):
yield from super().get_error_message_codes()
yield 'too_many'
def set_value(self, value):
for dummy in range(len(value) - len(self.element_names)):
@ -2375,7 +2463,7 @@ class WysiwygTextWidget(TextWidget):
try:
self.validation_function(self.value)
except ValueError as e:
self.error = str(e)
self.set_error(str(e))
if self.value == '':
self.value = None
@ -2623,9 +2711,20 @@ class MultiSelectWidget(MultipleSelectWidget):
finally:
self.name = orig_name
if self.value and self.min_choices and len(self.value) < self.min_choices:
self.set_error(_('You must select at least %d choices.') % self.min_choices)
self.set_error_code('too_short')
if self.value and self.max_choices and len(self.value) > self.max_choices:
self.set_error(_('You must select at most %d choices.') % self.max_choices)
self.set_error_code('too_long')
def get_too_short_message(self):
return _('You must select at least %d choices.') % self.min_choices
def get_too_long_message(self):
return _('You must select at most %d choices.') % self.max_choices
def get_error_message_codes(self):
yield from super().get_error_message_codes()
yield 'too_short'
yield 'too_long'
class WidgetListAsTable(WidgetList):
@ -2823,9 +2922,16 @@ class RankedItemsWidget(CompositeWidget):
if value is not None:
values[val] = value
if value is not None and not isinstance(value, int):
self.get_widget(key).set_error(IntWidget.TYPE_ERROR)
self.get_widget(key).set_error_code('type_mismatch')
self.value = values or None
def get_type_mismatch_message(self):
return _('must be a number')
def get_error_message_codes(self):
yield from super().get_error_message_codes()
yield 'type_mismatch'
def set_value(self, value):
self.value = value
if value:
@ -3282,7 +3388,7 @@ class MapWidget(CompositeWidget):
try:
lat, lon = self.value.split(';')
except ValueError:
self.set_error(_('Invalid value'))
self.set_error_code('bad_input')
else:
lat_lon = misc.normalize_geolocation({'lat': lat, 'lon': lon})
self.value = '%s;%s' % (lat_lon['lat'], lat_lon['lon']) if lat_lon else None

View File

@ -6,7 +6,12 @@
{% block widget-attrs %}
{% if widget.is_hidden %}style="display: none"{% endif %}
{% if widget.field %}data-field-id="{{ widget.field.id }}"{% endif %}
{% if not widget.readonly %}
{% if widget.field.validation.type %}data-validation-type="{{ widget.field.validation.type }}"{% endif %}
{% if widget.supports_live_validation %}data-supports-live-validation="true"{% endif %}
{% endif %}
data-widget-name="{{ widget.name }}"
data-widget-name-for-id="{{ widget.get_name_for_id }}"
{% if widget.div_id %}id="{{widget.div_id}}" data-valuecontainerid="form_{{widget.name}}"{% endif %}
{% if widget.a11y_role %}role="{{ a11y_role }}"{% endif %}
{% if widget.a11y_labelledby %}aria-labelledby="form_label_{{widget.name}}"{% endif %}
@ -45,4 +50,11 @@
{% if widget.render_br %}
<br class="content {{widget.content.content_extra_css_class}}">
{% endif %}
{% if widget.supports_live_validation and not widget.readonly %}
{% block widget-error-templates %}
{% for error_message in widget.get_error_messages %}
<template id="error_{{ widget.get_name_for_id }}_{{ error_message.camel_code }}"><p>{{ error_message.message }}</p></template>
{% endfor %}
{% endblock %}
{% endif %}
</div>