address standardization UI, part 1
authorMark Wells <mark@freeside.biz>
Thu, 29 Oct 2015 20:49:45 +0000 (13:49 -0700)
committerMark Wells <mark@freeside.biz>
Thu, 29 Oct 2015 20:49:45 +0000 (13:49 -0700)
FS/FS/Misc/Geo.pm
httemplate/docs/license.html
httemplate/edit/cust_main.cgi
httemplate/elements/freeside.css
httemplate/elements/jquery.deserialize.min.js [new file with mode: 0644]
httemplate/elements/location.html
httemplate/elements/polyfill.js [new file with mode: 0644]
httemplate/misc/address_standardize.cgi [new file with mode: 0644]

index 1aa5939..293748c 100644 (file)
@@ -342,7 +342,7 @@ sub standardize_uscensus {
     die "Geocoding did not find a matching address.\n";
   } else {
     warn Dumper($result) if $DEBUG;
     die "Geocoding did not find a matching address.\n";
   } else {
     warn Dumper($result) if $DEBUG;
-    die $result->error_message;
+    die $result->error_message."\n";
   }
 }
 
   }
 }
 
index 7e5bb1e..71643fc 100644 (file)
@@ -130,6 +130,8 @@ and other contributors, licensed under the terms of the MIT license.
 Contains the Spectrum No Hassle jQuery Colorpicker by Brian Grinstead, licensed
 under the terms of the MIT license.
 
 Contains the Spectrum No Hassle jQuery Colorpicker by Brian Grinstead, licensed
 under the terms of the MIT license.
 
+<P>
+Contains <a href="https://github.com/kflorence/jquery-deserialize/">jQuery.deserialize</a> by Kyle Florence, licensed under the terms of the MIT license.
 
 <!-- artwork -->
 
 
 <!-- artwork -->
 
index effe84b..2593201 100755 (executable)
@@ -43,6 +43,7 @@
   <TD>
 %#; padding-right:2px; vertical-align:top">
     <FONT CLASS="fsinnerbox-title"><% mt('Billing address') |h %></FONT>
   <TD>
 %#; padding-right:2px; vertical-align:top">
     <FONT CLASS="fsinnerbox-title"><% mt('Billing address') |h %></FONT>
+    <FIELDSET ID="bill_location" CLASS="location">
     <TABLE CLASS="fsinnerbox" WIDTH="100%">
     <& cust_main/before_bill_location.html, $cust_main &>
     <& /elements/location.html,
     <TABLE CLASS="fsinnerbox" WIDTH="100%">
     <& cust_main/before_bill_location.html, $cust_main &>
     <& /elements/location.html,
@@ -54,6 +55,7 @@
     &>
     <& cust_main/after_bill_location.html, $cust_main &>
     </TABLE>
     &>
     <& cust_main/after_bill_location.html, $cust_main &>
     </TABLE>
+    </FIELDSET>
   </TD>
 </TR>
 <TR><TD STYLE="height:14px"></TD></TR>
   </TD>
 </TR>
 <TR><TD STYLE="height:14px"></TD></TR>
@@ -68,7 +70,7 @@
            VALUE="Y"
            <% $has_ship_address ? '' : 'CHECKED' %>
     ><% mt('same as billing address') |h %>
            VALUE="Y"
            <% $has_ship_address ? '' : 'CHECKED' %>
     ><% mt('same as billing address') |h %>
-    <DIV ID="div_ship_location">
+    <FIELDSET ID="ship_location" CLASS="location">
       <TABLE WIDTH="100%" CLASS="fsinnerbox">
       <& cust_main/before_ship_location.html, $cust_main &>
       <& /elements/location.html,
       <TABLE WIDTH="100%" CLASS="fsinnerbox">
       <& cust_main/before_ship_location.html, $cust_main &>
       <& /elements/location.html,
@@ -91,7 +93,7 @@
         </TR>
 % }
       </TABLE>
         </TR>
 % }
       </TABLE>
-    </DIV>
+    </FIELDSET>
   </TD>
 </TR></TABLE>
 
   </TD>
 </TR></TABLE>
 
 
 function samechanged(what) {
   if ( what.checked ) {
 
 function samechanged(what) {
   if ( what.checked ) {
-    $('#div_ship_location').slideUp();
+    $('#ship_location').slideUp();
   } else {
   } else {
-    $('#div_ship_location').slideDown();
+    $('#ship_location').slideDown();
   }
 }
 
 % if ( ! $has_ship_address ) {
   }
 }
 
 % if ( ! $has_ship_address ) {
-  $('#div_ship_location').hide();
+  $('#ship_location').hide();
 % }
 
 % }
 
+$().ready( function() {
+  window.bill_location = new Location($('fieldset#bill_location'));
+});
+
 </SCRIPT>
 
 <& cust_main/contacts_new.html, 'cust_main'=>$cust_main, &>
 </SCRIPT>
 
 <& cust_main/contacts_new.html, 'cust_main'=>$cust_main, &>
index dbd27cb..5eb8f72 100644 (file)
@@ -335,3 +335,11 @@ div.package-marker-change_from {
   border-left: solid #bbffbb 30px;
   display: inline-block;
 }
   border-left: solid #bbffbb 30px;
   display: inline-block;
 }
+
+/* elements/location.html and co. */
+fieldset.location {
+  padding: 0px;
+  margin: 0px;
+  border: none;
+}
+
diff --git a/httemplate/elements/jquery.deserialize.min.js b/httemplate/elements/jquery.deserialize.min.js
new file mode 100644 (file)
index 0000000..7054ea4
--- /dev/null
@@ -0,0 +1,8 @@
+/**
+ * @author Kyle Florence <kyle[dot]florence[at]gmail[dot]com>
+ * @website https://github.com/kflorence/jquery-deserialize/
+ * @version 1.2.1
+ *
+ * Dual licensed under the MIT and GPLv2 licenses.
+ */
+(function(i,b){var f=Array.prototype.push,a=/^(?:radio|checkbox)$/i,e=/\+/g,d=/^(?:option|select-one|select-multiple)$/i,g=/^(?:button|color|date|datetime|datetime-local|email|hidden|month|number|password|range|reset|search|submit|tel|text|textarea|time|url|week)$/i;function c(j){return j.map(function(){return this.elements?i.makeArray(this.elements):this}).filter(":input").get()}function h(j){var k,l={};i.each(j,function(n,m){k=l[m.name];l[m.name]=k===b?m:(i.isArray(k)?k.concat(m):[k,m])});return l}i.fn.deserialize=function(A,l){var y,n,q=c(this),t=[];if(!A||!q.length){return this}if(i.isArray(A)){t=A}else{if(i.isPlainObject(A)){var B,w;for(B in A){i.isArray(w=A[B])?f.apply(t,i.map(w,function(j){return{name:B,value:j}})):f.call(t,{name:B,value:w})}}else{if(typeof A==="string"){var v;A=A.split("&");for(y=0,n=A.length;y<n;y++){v=A[y].split("=");f.call(t,{name:decodeURIComponent(v[0]),value:decodeURIComponent(v[1].replace(e,"%20"))})}}}}if(!(n=t.length)){return this}var u,k,x,z,C,o,m,w,p=i.noop,s=i.noop,r={};l=l||{};q=h(q);if(i.isFunction(l)){s=l}else{p=i.isFunction(l.change)?l.change:p;s=i.isFunction(l.complete)?l.complete:s}for(y=0;y<n;y++){u=t[y];C=u.name;w=u.value;if(!(k=q[C])){continue}m=(z=k.length)?k[0]:k;m=(m.type||m.nodeName).toLowerCase();o=null;if(g.test(m)){if(z){x=r[C];k=k[r[C]=(x==b)?0:++x]}p.call(k,(k.value=w))}else{if(a.test(m)){o="checked"}else{if(d.test(m)){o="selected"}}}if(o){if(!z){k=[k];z=1}for(x=0;x<z;x++){u=k[x];if(u.value==w){p.call(u,(u[o]=true)&&w)}}}}s.call(this);return this}})(jQuery);
index b5f0a96..d4c0f14 100644 (file)
@@ -19,6 +19,9 @@ Example:
 
 </%doc>
 
 
 </%doc>
 
+<SCRIPT SRC="<% $fsurl %>elements/jquery.deserialize.min.js"></SCRIPT>
+<SCRIPT SRC="<% $fsurl %>elements/polyfill.js"></SCRIPT>
+
 % if ( $opt{'alt_format'} ) {
 
 <TR>
 % if ( $opt{'alt_format'} ) {
 
 <TR>
@@ -324,6 +327,89 @@ Example:
   }
 
 </&>
   }
 
 </&>
+
+function Location(fieldset) {
+  if ( typeof fieldset == 'String' ) {
+    fieldset = $('#' + fieldset);
+  }
+  this.fieldset = $(fieldset);
+  var errorbox = document.createElement('DIV');
+  errorbox.className = 'error';
+  fieldset.append(errorbox); // after the <table>
+  $(errorbox).position({
+    my: 'left',
+    at: 'right+20px',
+    of: fieldset
+  });
+  this.errorbox = $(errorbox); // so we can find it
+
+  var img_tick = $('<IMG SRC="http://localhost/freeside/images/tick.png">');
+  var img_wait = $('<IMG SRC="http://localhost/freeside/images/wait-orange.gif">');
+
+  // get/set the serialized (URL parameter string) contents of the form fields
+  this.value = function(newvalue) {
+    if (newvalue) {
+      try {
+        this.fieldset.deserialize(newvalue);
+        this.errorbox.empty();
+        if ( newvalue['error'] ) {
+          this.errorbox.text(newvalue['error']);
+        } else {
+          this.errorbox.append(img_tick);
+        }
+      } catch(err) {
+        console.log("Couldn't parse returned data:\n" + newvalue);
+        // show an error also
+      }
+    }
+    return this.fieldset.serialize();
+  };
+
+  // send a standardization request and do something with the result
+  this.standardize = function(callback) {
+    this.errorbox.empty();
+    this.errorbox.append(img_wait);
+    $.ajax({
+      type: 'POST',
+      url: '<% $fsurl %>misc/address_standardize.cgi',
+      success: callback,
+      data: this.value()
+    });
+  };
+
+  // check if required fields are filled, and if so, standardize
+  var standardize_if_ready = function() {
+    var loc = this;
+    var ready = true;
+    var required_fields = this.fieldset.find(':data(required)');
+    for ( var i = 0; ready && i < required_fields.length; i++ ) {
+      if ( required_fields[i].prop('value').length == 0 ) {
+        ready = false;
+      }
+    }
+
+    if ( ready ) {
+      // pass the "value" method, prebound to the location object
+      this.standardize( this.value.bind(loc) );
+    }
+  };
+
+  // event handler; the Location object is passed in event.data
+  var location_change_timer;
+  var location_changed = function( ev ) {
+    if ( location_change_timer ) {
+      window.clearTimeout(location_change_timer);
+    }
+    location_change_timer = window.setTimeout(
+      standardize_if_ready.bind(ev.data),
+      2000
+    );
+  };
+
+  fieldset.find('input').on('change', this, location_changed);
+  fieldset.find('select').on('change', this, location_changed);
+}
+
 </SCRIPT>
 
 <%init>
 </SCRIPT>
 
 <%init>
diff --git a/httemplate/elements/polyfill.js b/httemplate/elements/polyfill.js
new file mode 100644 (file)
index 0000000..5e08a99
--- /dev/null
@@ -0,0 +1,30 @@
+// Function.bind(), not supported in IE8
+// polyfill from Mozilla Developer Network
+
+if (!Function.prototype.bind) {
+  Function.prototype.bind = function(oThis) {
+    if (typeof this !== 'function') {
+      // closest thing possible to the ECMAScript 5
+      // internal IsCallable function
+      throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
+    }
+
+    var aArgs   = Array.prototype.slice.call(arguments, 1),
+        fToBind = this,
+        fNOP    = function() {},
+        fBound  = function() {
+          return fToBind.apply(this instanceof fNOP
+                 ? this
+                 : oThis,
+                 aArgs.concat(Array.prototype.slice.call(arguments)));
+        };
+
+    if (this.prototype) {
+      // native functions don't have a prototype
+      fNOP.prototype = this.prototype; 
+    }
+    fBound.prototype = new fNOP();
+
+    return fBound;
+  };
+}
diff --git a/httemplate/misc/address_standardize.cgi b/httemplate/misc/address_standardize.cgi
new file mode 100644 (file)
index 0000000..d9ba550
--- /dev/null
@@ -0,0 +1,51 @@
+<% encode_json($return) %>\
+<%init>
+
+local $SIG{__DIE__}; #disable Mason error trap
+
+my $DEBUG = 0;
+
+my $conf = new FS::Conf;
+
+# figure out the prefix
+my $pre;
+foreach my $name ($cgi->param) {
+  if ($name =~ /^(\w*)address1$/) {
+    $pre = $1;
+    last;
+  }
+}
+die "no address1 field in location" if !defined($pre);
+
+# gather relevant fields
+my %old = ( map { $_ => scalar($cgi->param($pre . $_)) }
+  qw( company address1 address2 city state zip country )
+);
+
+my $cache = eval { FS::GeocodeCache->standardize(\%old) };
+$cache->set_coord;
+# don't do set_censustract here, though censustract may be set by now
+
+# give the fields their prefixed names back
+# except always name the error string 'error'
+my $error = delete($cache->{'error'}) || '';
+my %new = (
+  'changed' => 0,
+  'error' => $error,
+  map { $pre.$_, $cache->get($_) } keys %$cache
+);
+
+foreach ( qw(address1 address2 city state zip country) ) {
+  if ( $new{$pre.$_} ne $old{$pre.$_} ) {
+    $new{changed} = 1;
+    last;
+  }
+}
+
+# refold this to make it acceptable to jquery
+#my $return = [ map { { name => $_, value => $new{$_} } } keys %new ];
+my $return = \%new;
+warn "result:\n".encode_json($return) if $DEBUG;
+
+$r->content_type('application/json');
+</%init>