diff --git a/wcs/fields.py b/wcs/fields.py index 408167854..16898b030 100644 --- a/wcs/fields.py +++ b/wcs/fields.py @@ -876,6 +876,7 @@ class WidgetField(Field): prefill = {} widget_class = None + widget_supports_check_condition = True 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_check_condition = self.widget_supports_check_condition 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) @@ -1641,6 +1643,7 @@ class FileField(WidgetField): storage = 'default' widget_class = FileWithPreviewWidget + widget_supports_check_condition = True extra_attributes = [ 'file_type', 'max_file_size', @@ -3144,6 +3147,7 @@ class TableField(WidgetField): columns = None widget_class = TableWidget + widget_supports_check_condition = True def __init__(self, **kwargs): self.rows = [] @@ -3365,6 +3369,7 @@ class TableRowsField(WidgetField): columns = None widget_class = TableListRowsWidget + widget_supports_check_condition = True def __init__(self, **kwargs): self.columns = [] @@ -3626,6 +3631,7 @@ class RankedItemsField(WidgetField): items = [] randomize_items = False widget_class = RankedItemsWidget + widget_supports_check_condition = True anonymise = False def perform_more_widget_changes(self, form, kwargs, edit=True): @@ -3898,7 +3904,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_check_condition = False + return widget def fill_admin_form(self, form): super().fill_admin_form(form) diff --git a/wcs/forms/root.py b/wcs/forms/root.py index dd9fab8e4..b58cdd9ff 100644 --- a/wcs/forms/root.py +++ b/wcs/forms/root.py @@ -270,6 +270,7 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin): 'code', 'removedraft', 'live', + 'check_condition', ('go-to-backoffice', 'go_to_backoffice'), ] @@ -929,6 +930,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' + form.attrs['data-check-condition-url'] = ( + self.formdef.get_url(language=get_publisher().current_language) + 'check_condition' + ) + form.widgets.append( + HtmlWidget( + '''''' + ) + ) return form def create_view_form(self, *args, **kwargs): @@ -1656,6 +1667,47 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin): return result_error('form deserialization failed: %s' % e) return FormStatusPage.live_process_fields(form, formdata, displayed_fields) + def check_condition(self): + get_request().ignore_session = True + get_response().set_content_type('application/json') + + def result_error(reason): + return json.dumps({'result': 'error', 'reason': reason}) + + session = get_session() + if not (session and session.id): + return result_error('missing session') + + field_ref = get_request().form.get('field') + parts = field_ref.split('__') + for field in self.formdef.fields: + if 'f%s' % field.id == parts[0]: + break + else: + return result_error('missing field') + if len(parts) == 3: # block field + for subfield in field.block.fields: + if 'f%s' % subfield.id == parts[2]: + break + else: + return result_error('missing sub field') + field = subfield + field.id = field_ref[1:].replace('__', '$') + + form = Form() + widget = field.add_to_form(form) + widget.parse() + if widget.has_error(): + resp = {'err': 1, 'msg': str(widget.get_error())} + if hasattr(widget, 'error_code'): + from wcs.qommon.form import ErrorMessage + + error_message = ErrorMessage(widget.error_code, '') + 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)) @@ -2226,7 +2278,7 @@ class RootDirectory(AccessControlled, Directory): class PublicFormStatusPage(FormStatusPage): - _q_exports_orig = ['', 'download', 'status', 'live', 'tempfile'] + _q_exports_orig = ['', 'download', 'status', 'live', 'tempfile', 'check_condition'] form_page_class = FormPage history_templates = ['wcs/front/formdata_history.html', 'wcs/formdata_history.html'] status_templates = ['wcs/front/formdata_status.html', 'wcs/formdata_status.html'] diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py index 3809f5382..6114fc50a 100644 --- a/wcs/qommon/form.py +++ b/wcs/qommon/form.py @@ -176,6 +176,39 @@ def widget_get_name_for_id(self): return self.name.replace('$', '__') +class ErrorMessage: + def __init__(self, code, label): + self.code = code + self.label = label + + def camel_code(self): + return ''.join(x.lower() if i == 0 else x.capitalize() for i, x in enumerate(self.code.split('_'))) + + +def widget_set_error(self, error=None, error_code=None): + if error and error_code: + self.error = error + self.error_code = error_code + elif error_code: + self.error = getattr(self, 'get_%s_message' % error_code)() + self.error_code = error_code + else: + self.error = error + if self.error == Widget.REQUIRED_ERROR: + self.error_code = 'value_missing' + else: + self.error_code = 'invalid_value' + + +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): + yield 'value_missing' + + Widget.render = render Widget.cleanup = None Widget.render_error = render_error @@ -184,6 +217,11 @@ 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 +Widget.get_invalid_value_message = lambda x: _('Invalid value') +Widget.set_error = widget_set_error def file_render_content(self): @@ -578,16 +616,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(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 + + 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'] @@ -634,12 +681,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('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()} @@ -671,7 +729,7 @@ class CheckboxWidget(QuixoteCheckboxWidget): def _parse(self, request): self.value = self.name in request.form and not request.form[self.name] in (False, '', 'False') if self.required and not self.value: - self.set_error(self.REQUIRED_ERROR) + self.set_error(error_code='value_missing') def set_value(self, value): if value in (None, False, '', 'False'): @@ -874,7 +932,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: @@ -893,7 +951,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 @@ -929,7 +987,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: @@ -946,7 +1004,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: @@ -966,7 +1024,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): @@ -1000,37 +1058,37 @@ class EmailWidget(StringWidget): 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(_('must be a valid email address')) return if self.value[0] != '"' and ' ' in self.value: - self.error = _('must be a valid email address') + self.set_error(_('must be a valid email address')) return if self.value[0] != '"' and self.value.count('@') != 1: - self.error = _('must be a valid email address') + self.set_error(_('must be a valid email address')) 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(_('must be a valid email address')) 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: @@ -1368,7 +1426,6 @@ class WcsExtraStringWidget(StringWidget): prefill = False prefill_attributes = None validation_function = None - validation_function_error_message = None def add_media(self): if self.prefill_attributes and 'geolocation' in self.prefill_attributes: @@ -1380,13 +1437,22 @@ class WcsExtraStringWidget(StringWidget): self.inputmode = ValidationWidget.get_html_inputmode(self.field.validation) return super().render_content() + def get_error_message_codes(self): + yield from super().get_error_message_codes() + yield 'invalid_value' + + def get_invalid_value_message(self): + validation_function_error_message = None + 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: @@ -1394,7 +1460,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(error_code='invalid_value') if self.field and self.value and not self.error and self.field.validation: self.value = normalized_value @@ -1458,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(_('invalid date')) self.value = None return if value[0] < 1500 or value[0] > 2099: - self.error = _('invalid date') + self.set_error(_('invalid date')) 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): @@ -1529,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 @@ -1565,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 @@ -1579,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 @@ -1632,11 +1700,17 @@ class CheckboxesWidget(Widget): values.append(option['value']) self.value = values if self.required and not self.value: - self.set_error(self.REQUIRED_ERROR) + self.set_error(error_code='value_missing') 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(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(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 @@ -1664,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): @@ -1675,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): @@ -1687,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. @@ -1733,7 +1807,7 @@ class VarnameWidget(ValidatedStringWidget): 'with', 'yield', ): - self.error = _('this value is reserved for internal use.') + self.set_error(_('this value is reserved for internal use.')) class SlugWidget(ValidatedStringWidget): @@ -1757,7 +1831,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): @@ -1790,7 +1864,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): @@ -1840,7 +1914,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 @@ -1931,7 +2005,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(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)): @@ -2421,7 +2502,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 @@ -2513,7 +2594,7 @@ class TableWidget(CompositeWidget): request = get_request() if (request.form or request.get_method() == 'POST') and self.required: if not self.value: - self.set_error(self.REQUIRED_ERROR) + self.set_error(error_code='value_missing') else: for row in self.value: for column in row: @@ -2523,7 +2604,7 @@ class TableWidget(CompositeWidget): continue break else: - self.set_error(self.REQUIRED_ERROR) + self.set_error(error_code='value_missing') return self.value def _parse(self, request): @@ -2669,9 +2750,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(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(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): @@ -2754,7 +2846,7 @@ class TableListRowsWidget(WidgetListAsTable): add_element_pushed = self.get_widget('add_element').parse() if (request.form or request.get_method() == 'POST') and self.required: if not self.value and not add_element_pushed: - self.set_error(self.REQUIRED_ERROR) + self.set_error(error_code='value_missing') for row in self.value or []: for column in row: if column: @@ -2764,7 +2856,7 @@ class TableListRowsWidget(WidgetListAsTable): break else: if not add_element_pushed: - self.set_error(self.REQUIRED_ERROR) + self.set_error(error_code='value_missing') return self.value def _parse(self, request): @@ -2869,9 +2961,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(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: @@ -3328,11 +3427,15 @@ class MapWidget(CompositeWidget): try: lat, lon = self.value.split(';') except ValueError: - self.set_error(_('Invalid value')) + self.set_error(error_code='invalid_value') 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 + def get_error_message_codes(self): + yield from super().get_error_message_codes() + yield 'invalid_value' + def set_value(self, value): super().set_value(value) self.get_widget('latlng').set_value(value) @@ -3372,8 +3475,8 @@ class MapMarkerSelectionWidget(MapWidget): class HiddenErrorWidget(HiddenWidget): - def set_error(self, error): - Widget.set_error(self, error) + def set_error(self, *args, **kwargs): + Widget.set_error(self, *args, **kwargs) class SingleSelectWidgetWithOther(CompositeWidget): diff --git a/wcs/qommon/static/js/qommon.forms.js b/wcs/qommon/static/js/qommon.forms.js index 4e42afa7d..dee328282 100644 --- a/wcs/qommon/static/js/qommon.forms.js +++ b/wcs/qommon/static/js/qommon.forms.js @@ -756,3 +756,173 @@ $(function() { }); } }); + + +function check_condition(button, widget_id) { + var $form = $('form[data-check-condition-url]'); + var form_content = $form.serialize(); + $.ajax({ + type: 'POST', + url: $form.attr('data-check-condition-url') + '?field=' + widget_id, + data: form_content, + success: function(json) { + if (json.err === 0) { + button.textContent = 'ok'; + } else { + button.textContent = 'err: ' + json.msg; + } + }, + }); +} + + + +/* + * Live Field Validation + */ + +const LiveValidation = (function(){ + + const excludedField = function (field) { + if (field.disabled ) return true + const excludedType = [ 'button', 'reset', 'submit' ] + if (excludedType.includes(field.type)) return true + return false + } + + /* + * Check validy of field by HTML attributs + * cf JS constaint validation API + * return first error fouded + */ + const hasAttrError = function (field) { + const validityState = field.validity + if (validityState.valid) return + + let errorType + for (const key in validityState) { + if (validityState[key]) { + errorType = key + break + } + } + return errorType + } + + /* + * Check Validy of field by request to server + */ + const hasServerError = function (field, form, url) { + return fetch( url+field.name, { + method: 'POST', + body: new FormData(form) + }) + .then( (response) => response.json() ) + .then( (json) => { + if (json.err === 0) { + return + } else { + let errorType + for (const key in json) { + if (json[key] === true) { + errorType = key + break + } + } + return errorType + } + }) + } + + class FieldLiveValidation { + constructor (widget, formDatas) { + this.widget = widget + this.name = widget.dataset.widgetName + this.errorClass = "widget-with-error" + this.errorEl = this.setErrorEl(formDatas.errorTpl.content.children[0]) + this.checkUrl = formDatas.checkUrl + this.hasError = false + this.init() + } + + setErrorEl = function(errorTpl) { + const errorEl = document.importNode(errorTpl) + errorEl.id = errorEl.id.replace('fieldname', this.name) + return errorEl + } + + async toggleStatus(field) { + if (excludedField(field)) return + const attrError = hasAttrError(field); + const serverError = this.widget.dataset.supportsCheckCondition + ? await hasServerError(field, field.form, this.checkUrl) + : false + const error = attrError ? attrError : serverError + if (error) { + this.showError(field, error) + } else { + this.removeError(field, error) + } + } + + showError(field, error) { + if (this.hasError === error) return + this.widget.classList.add(this.errorClass) + const errorElMessage = document.getElementById(`error_${this.name}_${error}`).innerHTML + this.errorEl.innerHTML = errorElMessage + this.widget.appendChild(this.errorEl) + field.setAttribute("aria-invalid", "true") + field.setAttribute("aria-describedby", this.errorEl.id) + this.hasError = error + } + + removeError(field, error) { + if (error) return + this.errorEl.remove() + field.setAttribute("aria-invalid", "false") + field.setAttribute("aria-describedby", this.errorEl.id) + this.widget.classList.remove(this.errorClass) + this.hasError = false + } + + init() { + // Check if field is allready on error + if (this.widget.classList.contains(this.errorClass)) { + this.hasError = true; + // Check if error element exist allready + const existingErrorEl = document.getElementById(this.errorEl.id) + if (existingErrorEl) + this.errorEl = existingErrorEl; + } + + // Events + this.widget.addEventListener('blur', (event) => { + this.toggleStatus(event.target) + }, true); + // If field has Error, check when it change with debounce + let timeout; + this.widget.addEventListener('input', (event) => { + if (this.hasError) { + clearTimeout(timeout) + timeout = setTimeout(() => { + this.toggleStatus(event.target) + }, 500) + } + }, true); + } + } + + return FieldLiveValidation +})() + +document.addEventListener('DOMContentLoaded', function(){ + const form = document.querySelector('form[data-check-condition-url]') + const formWidgets = form.querySelectorAll('.widget') + let formDatas = { + errorTpl: document.getElementById('form_error_tpl'), + checkUrl: form.dataset.checkConditionUrl + '?field=', + } + formWidgets.forEach((widget) => { + new LiveValidation(widget, formDatas) + }) +}) diff --git a/wcs/qommon/templates/qommon/forms/widget.html b/wcs/qommon/templates/qommon/forms/widget.html index cf0f512ae..21d2cd2f9 100644 --- a/wcs/qommon/templates/qommon/forms/widget.html +++ b/wcs/qommon/templates/qommon/forms/widget.html @@ -6,6 +6,10 @@ {% 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_check_condition %}data-supports-check-condition="true"{% endif %} + {% endif %} data-widget-name="{{ widget.name }}" {% if widget.div_id %}id="{{widget.div_id}}" data-valuecontainerid="form_{{widget.name}}"{% endif %} {% if widget.a11y_role %}role="{{ a11y_role }}"{% endif %} @@ -45,4 +49,14 @@ {% if widget.render_br %}
{% endif %} + {% if widget.supports_check_condition %} + {% block widget-error-templates %} + {% if not widget.readonly %} + {% for error_message in widget.get_error_messages %} + + {% endfor %} + {% endif %} + {% endblock %} + + {% endif %}