commit ccfd9aace3d2c0a1199668e36d2deadbb871bd9b
Author: Sven Schöling <s.schoeling@googlemail.com>
Date:   Fri Oct 13 15:24:28 2017 +0200

    kivi.Validator

diff --git a/js/kivi.Validator.js b/js/kivi.Validator.js
new file mode 100644
index 000000000..c8e02c63e
--- /dev/null
+++ b/js/kivi.Validator.js
@@ -0,0 +1,133 @@
+namespace("kivi.Validator", function(ns) {
+  "use strict";
+
+  // Performs various validation steps on the descendants of
+  // 'selector'. Elements that should be validated must have an
+  // attribute named "data-validate" which is set to a space-separated
+  // list of tests to perform. Additionally, the attribute
+  // "data-title" must be set to a human-readable name of the field
+  // that can be shown as part of an error message.
+  //
+  // Supported validation tests are:
+  // - "required": the field must be set (its .val() must not be empty)
+  //
+  // The validation will abort and return "false" as soon as
+  // validation routine fails.
+  //
+  // The function returns "true" if all validations succeed for all
+  // elements.
+  ns.validate_all = function(selector) {
+    selector = selector || '#form';
+    var to_check = $(selector + ' [data-validate]').toArray();
+
+    for (var to_check_idx in to_check)
+      if (!ns.validate($(to_check[to_check_idx])))
+        return false;
+
+    return true;
+  };
+
+  ns.validate = function($e) {
+    var tests = $e.data('validate').split(/ +/);
+
+    for (var test_idx in tests) {
+      var test = tests[test_idx];
+
+      if (ns.checks[test]) {
+        if (!ns.checks[test]($e))
+          return false;
+      } else {
+        var error = "kivi.validate_form: unknown test '" + test + "' for element ID '" + $e.prop('id') + "'";
+        console.error(error);
+        alert(error);
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  ns.checks = {
+    required: function($e) {
+      if ($e.val() === '') {
+        ns.annotate($e, kivi.t8("This field must not be empty."));
+        return false;
+      } else {
+        ns.annotate($e);
+        return true;
+      }
+    },
+    number: function($e) {
+      var number_string = $e.val();
+
+      var parsed_number = kivi.parse_amount(number_string);
+
+      if (parsed_number === null) {
+        $e.val('');
+        ns.annotate($e);
+        return true;
+      } else
+      if (parsed_number === undefined) {
+        ns.annotate($e, kivi.t8('Wrong number format (#1)', [ kivi.myconfig.numberformat ]));
+        return false;
+      } else
+      {
+        var formatted_number = kivi.format_amount(parsed_number);
+        if (formatted_number != number_string)
+          $e.val(formatted_number);
+        ns.annotate($e);
+        return true;
+      }
+    },
+    date: function($e) {
+      var date_string = $e.val();
+
+      var parsed_date = kivi.parse_date(date_string);
+
+      if (parsed_number === null) {
+        $e.val('');
+        ns.annotate($e);
+        return true;
+      } else
+      if (parsed_date === undefined) {
+        ns.annotate($e, kivi.t8('Wrong date format (#1)', [ kivi.myconfig.dateformat ]));
+        return false;
+      } else
+      {
+        var formatted_date = kivi.format_date(parsed_date);
+        if (formatted_date != date_string)
+          $e.val(formatted_date);
+        ns.annotate($e);
+        return true;
+      }
+    }
+  };
+
+  ns.annotate = function($e, error) {
+    if (error) {
+      $e.addClass('kivi-validator-invalid');
+      if ($e.hasClass('tooltipstered'))
+        $e.tooltipster('destroy');
+
+      $e.tooltipster({
+        content: error,
+        theme: 'tooltipster-light',
+      });
+      $e.tooltipster('show');
+    } else {
+      $e.removeClass('kivi-validator-invalid');
+      if ($e.hasClass('tooltipstered'))
+        $e.tooltipster('destroy');
+    }
+  };
+
+  ns.reinit_widgets = function() {
+    kivi.run_once_for('[data-validate]', 'data-validate', function(elt) {
+      $(elt).change(function(event){ ns.validate($(elt), event) });
+    });
+  }
+
+  ns.init = ns.reinit_widgets;
+
+  $(ns.init);
+});
diff --git a/js/kivi.js b/js/kivi.js
index ca9c1cb32..8e190020f 100644
--- a/js/kivi.js
+++ b/js/kivi.js
@@ -31,6 +31,12 @@ namespace("kivi", function(ns) {
   };
 
   ns.parse_date = function(date) {
+    if (date === undefined)
+      return undefined;
+
+    if (date === '')
+      return null;
+
     var parts = date.replace(/\s+/g, "").split(ns._date_format.sep);
     var today = new Date();
 
@@ -91,8 +97,11 @@ namespace("kivi", function(ns) {
   };
 
   ns.parse_amount = function(amount) {
-    if ((amount === undefined) || (amount === ''))
-      return 0;
+    if (amount === undefined)
+      return undefined;
+
+    if (amount === '')
+      return null;
 
     if (ns._number_format.decimalSep == ',')
       amount = amount.replace(/\./g, "").replace(/,/g, ".");
@@ -101,7 +110,7 @@ namespace("kivi", function(ns) {
 
     // Make sure no code wich is not a math expression ends up in eval().
     if (!amount.match(/^[0-9 ()\-+*/.]*$/))
-      return 0;
+      return undefined;
 
     amount = amount.replace(/^0+(\d+)/, '$1');
 
@@ -109,7 +118,7 @@ namespace("kivi", function(ns) {
     try {
       return eval(amount);
     } catch (err) {
-      return 0;
+      return undefined;
     }
   };
 
@@ -283,6 +292,7 @@ namespace("kivi", function(ns) {
 
     if (ns.Part) ns.Part.reinit_widgets();
     if (ns.CustomerVendor) ns.CustomerVendor.reinit_widgets();
+    if (ns.Validator) ns.Validator.reinit_widgets();
 
     if (ns.ProjectPicker)
       ns.run_once_for('input.project_autocomplete', 'project_picker', function(elt) {
@@ -499,6 +509,14 @@ namespace("kivi", function(ns) {
       console.log('No duplicate IDs found :)');
   };
 
+  ns.validate_form = function(selector) {
+    if (!kivi.Validator) {
+      console.log('kivi.Validator is not loaded');
+    } else {
+      kivi.Validator.validate_all(selector);
+    }
+  };
+
   // Verifies that at least one checkbox matching the
   // "checkbox_selector" is actually checked. If not, an error message
   // is shown, and false is returned. Otherwise (at least one of them
@@ -514,62 +532,6 @@ namespace("kivi", function(ns) {
     return false;
   };
 
-  // Performs various validation steps on the descendants of
-  // 'selector'. Elements that should be validated must have an
-  // attribute named "data-validate" which is set to a space-separated
-  // list of tests to perform. Additionally, the attribute
-  // "data-title" must be set to a human-readable name of the field
-  // that can be shown as part of an error message.
-  //
-  // Supported validation tests are:
-  // - "required": the field must be set (its .val() must not be empty)
-  //
-  // The validation will abort and return "false" as soon as
-  // validation routine fails.
-  //
-  // The function returns "true" if all validations succeed for all
-  // elements.
-  ns.validate_form = function(selector) {
-    var validate_field = function(elt) {
-      var $elt  = $(elt);
-      var tests = $elt.data('validate').split(/ +/);
-      var info  = {
-        title: $elt.data('title'),
-        value: $elt.val(),
-      };
-
-      for (var test_idx in tests) {
-        var test = tests[test_idx];
-
-        if (test === "required") {
-          if ($elt.val() === '') {
-            alert(kivi.t8("The field '#{title}' must be set.", info));
-            return false;
-          }
-
-        } else {
-          var error = "kivi.validate_form: unknown test '" + test + "' for element ID '" + $elt.prop('id') + "'";
-          console.error(error);
-          alert(error);
-
-          return false;
-        }
-      }
-
-      return true;
-    };
-
-    selector = selector || '#form';
-    var ok   = true;
-    var to_check = $(selector + ' [data-validate]').toArray();
-
-    for (var to_check_idx in to_check)
-      if (!validate_field(to_check[to_check_idx]))
-        return false;
-
-    return true;
-  };
-
   ns.switch_areainput_to_textarea = function(id) {
     var $input = $('#' + id);
     if (!$input.length)
diff --git a/js/locale/de.js b/js/locale/de.js
index f58cb9f8a..b296b9e80 100644
--- a/js/locale/de.js
+++ b/js/locale/de.js
@@ -56,7 +56,6 @@ namespace("kivi").setupLocale({
 "Edit text block":"Textblock bearbeiten",
 "Enter longdescription":"Langtext eingeben",
 "Error: Name missing":"Fehler: Name fehlt",
-"Falsches Datumsformat!":"Falsches Datumsformat!",
 "File upload":"Datei Upload",
 "Function block actions":"Funktionsblockaktionen",
 "Generate and print sales delivery orders":"Erzeuge und drucke Lieferscheine",
@@ -110,7 +109,6 @@ namespace("kivi").setupLocale({
 "The URL is missing.":"URL fehlt",
 "The action can only be executed once.":"Die Aktion kann nur einmal ausgeführt werden.",
 "The description is missing.":"Die Beschreibung fehlt.",
-"The field '#{title}' must be set.":"Das Feld »#{title}« muss gesetzt sein.",
 "The name is missing.":"Der Name fehlt.",
 "The name must only consist of letters, numbers and underscores and start with a letter.":"Der Name darf nur aus Buchstaben (keine Umlaute), Ziffern und Unterstrichen bestehen und muss mit einem Buchstaben beginnen.",
 "The option field is empty.":"Das Optionsfeld ist leer.",
@@ -123,6 +121,7 @@ namespace("kivi").setupLocale({
 "There are duplicate parts at positions":"Es gibt doppelte Artikel bei den Positionen",
 "There are still transfers not matching the qty of the delivery order. Stock operations can not be changed later. Do you really want to proceed?":"Einige der Lagerbewegungen sind nicht vollständig und Lagerbewegungen können nachträglich nicht mehr verändert werden. Wollen Sie wirklich fortfahren?",
 "There is one or more sections for which no part has been assigned yet; therefore creating the new record is not possible yet.":"Es gibt einen oder mehrere Abschnitte ohne Artikelzuweisung; daher kann der neue Beleg noch nicht erstellt werden.",
+"This field must not be empty.":"Dieses Feld darf nicht leer sein.",
 "This sales order has an active configuration for periodic invoices. If you save then all subsequently created invoices will contain those changes as well, but not those that have already been created. Do you want to continue?":"Dieser Auftrag besitzt eine aktive Konfiguration für wiederkehrende Rechnungen. Wenn Sie jetzt speichern, so werden alle zukünftig hieraus erzeugten Rechnungen die Änderungen enthalten, nicht aber die bereits erzeugten Rechnungen. Wollen Sie speichern?",
 "Time/cost estimate actions":"Aktionen für Kosten-/Zeitabschätzung",
 "Title":"Titel",
@@ -132,6 +131,8 @@ namespace("kivi").setupLocale({
 "Update quotation/order":"Auftrag/Angebot aktualisieren",
 "Vendor missing!":"Lieferant fehlt!",
 "Version actions":"Aktionen für Versionen",
+"Wrong date format (#1)":"Falsches Datumsformat (#1)",
+"Wrong number format (#1)":"Falsches Zahlenformat (#1)",
 "Yes":"Ja",
 "filename has not uploadable characters ":"Bitte Dateinamen ändern. Er hat für den Upload nicht verwendbare Sonderzeichen ",
 "filesize too big: ":"Datei zu groß: ",
@@ -139,6 +140,5 @@ namespace("kivi").setupLocale({
 "sort items":"Positionen sortieren",
 "start upload":"Hochladen beginnt",
 "time and effort based position":"Aufwandsposition",
-"uploaded":"Hochgeladen",
-"wrongformat":"Falsches Format"
+"uploaded":"Hochgeladen"
 });
diff --git a/locale/de/all b/locale/de/all
index 2495b5f7f..0d025a904 100755
--- a/locale/de/all
+++ b/locale/de/all
@@ -1351,7 +1351,6 @@ $self->{texts} = {
   'Extended status'             => 'Erweiterter Status',
   'Extension Of Time'           => 'Dauerfristverlängerung',
   'Factor'                      => 'Faktor',
-  'Falsches Datumsformat!'      => 'Falsches Datumsformat!',
   'Fax'                         => 'Fax',
   'Features'                    => 'Features',
   'Feb'                         => 'Feb',
@@ -3158,7 +3157,6 @@ $self->{texts} = {
   'The export failed because of malformed transactions. Please fix those before exporting.' => 'Es sind fehlerhafte Buchungen im Exportzeitraum vorhanden. Bitte korrigieren Sie diese vor dem Export.',
   'The factor is missing in row %d.' => 'Der Faktor fehlt in Zeile %d.',
   'The factor is missing.'      => 'Der Faktor fehlt.',
-  'The field \'#{title}\' must be set.' => 'Das Feld »#{title}« muss gesetzt sein.',
   'The file has been sent to the printer.' => 'Die Datei wurde an den Drucker geschickt.',
   'The file is available for download.' => 'Die Datei ist zum Herunterladen verfügbar.',
   'The file name is missing'    => 'Der Dateiname fehlt',
@@ -3403,6 +3401,7 @@ $self->{texts} = {
   'This discount is only valid in sales documents' => 'Dieser Rabatt ist nur in Verkaufsdokumenten gültig',
   'This export will include all records in the given time range and all supplicant information from checked entities. You will receive a single zip file. Please extract this file onto the data medium requested by your auditor.' => 'Dieser Export umfasst alle Belege im gewählten Zeitrahmen und die dazugehörgen Informationen aus den gewählten Blöcken. Sie erhalten eine einzelne Zip-Datei. Bitte entpacken Sie diese auf das Medium das Ihr Steuerprüfer wünscht.',
   'This feature especially prevents mistakes by mixing up prior tax and sales tax.' => 'Dieses Feature vermeidet insbesondere Verwechslungen von Umsatz- und Vorsteuer.',
+  'This field must not be empty.' => 'Dieses Feld darf nicht leer sein.',
   'This function requires the presence of articles with a time-based unit such as "h" or "min".' => 'Für diese Funktion mussen Artikel mit einer Zeit-basierten Einheit wie "Std" oder "min" existieren.',
   'This general ledger transaction has not been posted yet.' => 'Die Dialogbuchung wurde noch nicht gebucht.',
   'This group is valid for the following clients' => 'Diese Gruppe ist für die folgenden Mandanten gültig',
@@ -3746,7 +3745,9 @@ $self->{texts} = {
   'Working copy; no description yet' => 'Arbeitskopie; noch keine Beschreibung',
   'Working on export'           => 'Generiere Export',
   'Write bin to default bin in part?' => 'Diesen Lagerplatz als Standardlagerplatz im Artikel setzen?',
+  'Wrong date format (#1)'      => 'Falsches Datumsformat (#1)',
   'Wrong field value \'#1\' for field \'#2\' for the transaction with amount \'#3\'' => 'Falscher Feldwert \'#1\' für Feld \'#2\' bei der Transaktion mit dem Umsatz von \'#3\'',
+  'Wrong number format (#1)'    => 'Falsches Zahlenformat (#1)',
   'Wrong tax keys recorded'     => 'Gespeicherte Steuerschlüssel sind falsch',
   'Wrong taxes recorded'        => 'Gespeicherte Steuern passen nicht zum Steuerschlüssel',
   'X'                           => 'X',
@@ -4090,7 +4091,6 @@ $self->{texts} = {
   'without skonto'              => 'ohne Skonto',
   'without_skonto'              => 'ohne Skonto',
   'working copy'                => 'Arbeitskopie',
-  'wrongformat'                 => 'Falsches Format',
   'yearly'                      => 'jährlich',
   'yes'                         => 'ja',
   'you can find professional help.' => 'finden Sie professionelle Hilfe.',
