LP#1553813 Patron editor validation / perm checks.
authorBill Erickson <berickxx@gmail.com>
Thu, 25 Feb 2016 04:07:54 +0000 (23:07 -0500)
committerGalen Charlton <gmc@esilibrary.com>
Mon, 14 Mar 2016 21:53:43 +0000 (17:53 -0400)
1. Adds support for enforcing ui.patron.edit.*.require and
ui.patron.edit.*.regex org unit settings via Angular's ng-pattern and
ng-required attributes.

2. Supports selecting only valid profile groups and home org units.

3. Warns the user when a duplicate barcode or username is encountered.

When any fields in the form are invalid, the save options are disabled.

==

Adds support for enforcing the following permissions:

UPDATE_USER
CREATE_USER
CREATE_USER_GROUP_LINK
UPDATE_PATRON_COLLECTIONS_EXEMPT
UPDATE_PATRON_CLAIM_RETURN_COUNT
UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT
UPDATE_PATRON_ACTIVE_CARD
UPDATE_PATRON_PRIMARY_CARD

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Galen Charlton <gmc@esilibrary.com>
Open-ILS/src/templates/staff/circ/patron/reg_actions.tt2
Open-ILS/src/templates/staff/circ/patron/t_edit.tt2
Open-ILS/src/templates/staff/circ/patron/t_patron_cards_dialog.tt2
Open-ILS/src/templates/staff/css/circ.css.tt2
Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js

index a533ba8..1508018 100644 (file)
 <div>
   <span class="pad-all-min">
     <button type="button" class="btn btn-default" 
+      ng-disabled="edit_passthru.hide_save_actions()"
       ng-click="edit_passthru.save()">[% l('Save') %]</button>
   </span>
   <span class="pad-all-min">
     <button type="button" class="btn btn-default"
+      ng-disabled="edit_passthru.hide_save_actions()"
       ng_click="edit_passthru.save({clone:true})">[% l('Save & Clone') %]</button>
   </span>
 </div>
index c1e769e..6a5fcc2 100644 (file)
@@ -19,7 +19,6 @@
 <div ng-if="patron_id"
     class="strong-text-2">[% l('Patron Edit') %]</div>
 
-
 <div id="reg-alert-pane">
 
   <div id="reg-dupe-links">
   </div>
 </div>
 
-
-[% MACRO formfield(cls, field, path, input_type) BLOCK;
-
-  # input field generator for common text/number/checkbox fields
-
-  IF NOT input_type; input_type = 'text'; END %] 
-
-<div class="row reg-field-row" 
-  ng-show="show_field('[% cls _ '.' _ field %]')">
-
+[% MACRO draw_field_label (cls, field) BLOCK %]
   <div class="col-md-3 reg-field-label"> <!-- field label -->
-
     <label>{{idl_fields.[% cls %].[% field %].label}}</label>
-
     <!-- field documentation img/link -->
     <img ng-show="field_doc.[% cls %].[% field %]" 
       ng-click="set_selected_field_doc('[% cls %]','[% field %]')"
       src='[% DOC_IMG %]'></img>
   </div>
+[% END %]
 
-  <div class="col-md-3 reg-field-input"> <!-- field form input -->
 
-  [% model = path ? 'patron.' _ path _ '.' _ field : 'patron.' _ field %]
+[% 
+# draws a vanilla form input field for inputs that require no 
+# special additions.
+MACRO draw_form_input(cls, field, path, type, disable) BLOCK;
+  IF !type; type = 'text'; END;
+  base_obj = path ? 'patron.' _ path : 'patron';
+  model = base_obj _ '.' _ field;
+%]
+  <div class="col-md-3 reg-field-input">
+    <input 
+      type="[% type %]" 
+      class="form-control" 
+      name="[% model %]"
+      ng-change="field_modified()" 
+      ng-required="field_required('[% cls %]', '[% field %]')"
+      ng-blur="handle_field_changed([% base_obj %], '[% field %]')"
+      ng-pattern="field_pattern('[% cls %]', '[% field %]')"
+      [% IF disable %]ng-disabled="[% disable %]"[% END %]
+      ng-model="[% model %]"/>
+  </div>
+[% END %]
 
-  [% IF input_type == 'checkbox' %]
+[% MACRO draw_example_text(cls, field) BLOCK;
+  set_str = "org_settings['ui.patron.edit." _ cls _ "." _ field _ ".example']";
+%]
+  <span ng-if="[% set_str %]">
+    [% l('Example: [_1]', '{{' _ set_str _ '}}') %]
+  </span>
+[% END %]
 
-    <div class='checkbox'>
-      <input type='checkbox' ng-model='[% model %]'/>
+<!-- progress dialog displayed as we await all data to finish loading -->
+<div class="row" ng-show="!page_data_loaded">
+  <div class="col-md-6 pad-vert">
+    <div class="progress progress-striped active">
+        <div class="progress-bar"  role="progressbar" aria-valuenow="100" 
+              aria-valuemin="0" aria-valuemax="100" style="width: 100%">
+            <span class="sr-only">[% l('Loading...') %]</span>
+        </div>
     </div>
+  </div>
+</div>
 
-  [% ELSE %]
-    <!-- text / number input -->
-
-    [% IF field == 'alert_message' %]
-      <textarea ng-change="field_modified()" 
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field == 'post_code' %]
-      <input type="text" ng-change="field_modified()" 
-        ng-blur="post_code_changed(patron.[% path %])"
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field == 'barcode' %]
+<!--  
+MAIN FORM
+This div wraps the entire form so we can hide it until all needed data
+has been loaded.  Setting ng-form and a name lets us refer to fields
+within the "form" by name for validation.
+-->
+<div ng-form id="patron-reg-container" 
+  name="reg_form" ng-show="page_data_loaded">
+
+<!-- BARCODE -->
+
+<div class="row reg-field-row" ng-show="show_field('ac.barcode')">
+  [% draw_field_label('ac', 'barcode') %]
+  <div class="col-md-3 reg-field-input"> <!-- field form input -->
       <input type="text" 
+        name="barcode"
+        ng-model="patron.card.barcode"
+        ng-pattern="field_pattern('ac', 'barcode')"
+        ng-required="field_required('ac', 'barcode')"
         focus-me="focus_bc"
         ng-change="field_modified()" 
         ng-disabled="disable_bc"
-        ng-blur="barcode_changed(patron.card.barcode)"
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field == 'usrname' %]
-      <input type="text" 
-        focus-me="focus_usrname"
-        ng-change="field_modified()" 
-        ng-blur="usrname_changed(patron.usrname)"
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field == 'day_phone' %]
-      <input type="text" 
-        ng-blur="day_phone_changed(patron.day_phone)"
-        ng-change="field_modified()" 
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field.match('phone') %]
-      <input type="text" 
-        ng-change="field_modified()" 
-        ng-blur="dupe_value_changed('phone', patron.[% field %])"
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field.match('ident_value') %]
-      <input type="text" 
-        ng-change="field_modified()" 
-        ng-blur="dupe_value_changed('ident', patron.[% field %])"
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field == 'first_given_name' OR field == 'family_name' %]
-      <input type="text" 
-        ng-change="field_modified()" 
-        ng-blur="dupe_value_changed('name', patron.[% field %])"
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field == 'email' %]
-      <input type="[% input_type %]" 
-        ng-change="field_modified()" 
-        ng-blur="dupe_value_changed('email', patron.email)"
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSIF field.match('street') OR field == 'city' %]
-      <!-- note: passing address object to dupe_value_changed -->
-      <input type="[% input_type %]" 
-        ng-change="field_modified()" 
-        ng-blur="dupe_value_changed('address', patron.[% path %])"
-        class="form-control" ng-model="[% model %]"/>
-    [% ELSE %]
-      <input type="[% input_type %]" 
-        ng-change="field_modified()" 
-        class="form-control" ng-model="[% model %]"/>
-    [% END %]
-  [% END %]
-
+        class="form-control" 
+        ng-blur="handle_field_changed(patron.card, 'barcode')"/>
   </div>
-
-  <!-- supplemental actions and example text -->
   <div class="col-md-6 patron-reg-example">
-
-    [% IF field == 'barcode' %]
-
       <button class="btn btn-default" ng-show="!patron.isnew"
         ng-click="replace_card()">[% l('Replace Barcode') %]</button>
       <button class="btn btn-default" 
         ng-click="cards_dialog()">[% l('See All') %]</button>
+      <div ng-show="dupe_barcode" class="patron-reg-validation-alert">
+        <span>[% l('Barcode is already in use') %]</span>
+      </div>
+  </div>
+</div>
 
-    [% ELSIF field == 'passwd' %]
+<!-- USRNAME -->
 
-      <button class="btn btn-default" ng-click="generate_password()">
-        [% l('Generate Password') %]</button>
+<div class="row reg-field-row" ng-show="show_field('au.usrname')">
+  [% draw_field_label('au', 'usrname') %]
+  <div class="col-md-3 reg-field-input">
+    <input type="text" 
+      name='usrname'
+      ng-required="field_required('au', 'usrname')"
+      focus-me="focus_usrname"
+      ng-change="field_modified()" 
+      ng-pattern="field_pattern('au', 'usrname')"
+      ng-blur="handle_field_changed(patron, 'usrname')"
+      class="form-control" 
+      ng-model="patron.usrname"/>
+  </div>
+  <div class="col-md-6 patron-reg-example">
+    <div ng-show="dupe_username" class="patron-reg-validation-alert">
+      <span>[% l('Username is already in use') %]</span>
+    </div>
+  </div>
+</div>
 
-    [% ELSE %]
+<!-- PASSWD -->
 
-      <!-- invalidate buttons -->
+<div class="row reg-field-row" ng-show="show_field('au.passwd')">
+  [% draw_field_label('au', 'passwd') %]
+  [% draw_form_input('au', 'passwd'); %]
+  <div class="col-md-6 patron-reg-example">
+    <button class="btn btn-default" ng-click="generate_password()">
+      [% l('Generate Password') %]</button>
+  </div>
+</div>
 
-      [% IF field.match('phone') OR field.match('email') %]
-        <button ng-show="patron.[% field %] && !patron.isnew" 
-            class="btn btn-default" 
-            ng-click="invalidate_field('[% field %]')">
-            [% l('Invalidate') %]
-        </button>
-      [% END %]
+<!-- PREFIX -->
 
-      <!-- example strings -->
+<div class="row reg-field-row" ng-show="show_field('au.prefix')">
+  [% draw_field_label('au', 'prefix') %]
+  [% draw_form_input('au', 'prefix'); %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'prefix') %]
+  </div>
+</div>
 
-      [% set_str = "org_settings['ui.patron.edit." _ 
-          cls _ "." _ field _ ".example']"; %]
+<!-- FIRST_GIVEN_NAME -->
 
-      <span ng-if="[% set_str %]">
-        [% l('Example: [_1]', "{{" _ set_str _ "}}") %]
-      </span>
+<div class="row reg-field-row" ng-show="show_field('au.first_given_name')">
+  [% draw_field_label('au', 'first_given_name') %]
+  [% draw_form_input('au', 'first_given_name'); %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'first_given_name') %]
+  </div>
+</div>
 
-      <!-- phones have a fall-through example strings -->
-      [% IF field.match('phone') %]
-        <span ng-if="![% set_str %] && org_settings['ui.patron.edit.phone.example']">
-          [% l('Example: [_1]', 
-          "{{org_settings['ui.patron.edit.phone.example']}}") %]
-        </span>
-      [% END %]
-    [% END %]
+<!-- SECOND_GIVEN_NAME -->
+
+<div class="row reg-field-row" ng-show="show_field('au.second_given_name')">
+  [% draw_field_label('au', 'second_given_name') %]
+  [% draw_form_input('au', 'second_given_name'); %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'second_given_name') %]
   </div>
 </div>
-[% END %]
 
-<!-- progress dialog displayed as we await all data to finish loading -->
-<div class="row" ng-show="!page_data_loaded">
-  <div class="col-md-6 pad-vert">
-    <div class="progress progress-striped active">
-        <div class="progress-bar"  role="progressbar" aria-valuenow="100" 
-              aria-valuemin="0" aria-valuemax="100" style="width: 100%">
-            <span class="sr-only">[% l('Loading...') %]</span>
-        </div>
-    </div>
+<!-- FAMILY_NAME -->
+
+<div class="row reg-field-row" ng-show="show_field('au.family_name')">
+  [% draw_field_label('au', 'family_name') %]
+  [% draw_form_input('au', 'family_name'); %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'family_name') %]
   </div>
 </div>
 
-<!-- this div wraps the entire form so we can hide it 
-     until all needed data has been loaded -->
-<div ng-show="page_data_loaded"><!-- form wrapper -->
+<!-- SUFFIX -->
 
-[% formfield('ac', 'barcode', 'card') %]
-[% formfield('au', 'usrname') %]
-[% formfield('au', 'passwd') %]
-[% formfield('au', 'prefix') %]
-[% formfield('au', 'first_given_name') %]
-[% formfield('au', 'second_given_name') %]
-[% formfield('au', 'family_name') %]
-[% formfield('au', 'suffix') %]
-[% formfield('au', 'alias') %]
+<div class="row reg-field-row" ng-show="show_field('au.suffix')">
+  [% draw_field_label('au', 'suffix') %]
+  [% draw_form_input('au', 'suffix'); %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'suffix') %]
+  </div>
+</div>
 
-<div class="row reg-field-row" ng-show="show_field('au.dob')">
-  <div class="col-md-3 reg-field-label">
-    <label>{{idl_fields.au.dob.label}}</label>
-    <img ng-show="field_doc.au.dob" 
-      ng-click="selected_field_doc=field_doc.au.dob"
-      src='[% DOC_IMG %]'></img>
+<!-- ALIAS -->
+
+<div class="row reg-field-row" ng-show="show_field('au.alias')">
+  [% draw_field_label('au', 'alias') %]
+  [% draw_form_input('au', 'alias'); %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'alias') %]
   </div>
+</div>
+
+<!-- DOB -->
+
+<div class="row reg-field-row" ng-show="show_field('au.dob')">
+  [% draw_field_label('au', 'dob') %]
   <div class="col-md-3 reg-field-input">
     <input eg-date-input 
+      name="dob"
       ng-change="field_modified()" 
+      ng-required="field_required('au', 'dob')"
+      ng-blur="handle_field_changed(patron, 'dob')"
       class="form-control" ng-model="patron.dob"/>
   </div>
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'dob') %]
+  </div>
 </div>
 
-[% formfield('au', 'juvenile', '', 'checkbox') %]
+<!-- JUVENILE -->
+
+<div class="row reg-field-row" ng-show="show_field('au.juvenile')">
+  [% draw_field_label('au', 'juvenile') %]
+  [% draw_form_input('au', 'juvenile', '', 'checkbox'); %]
+</div>
 
 <!-- ident_type -->
 
 <div class="row reg-field-row" ng-show="show_field('au.ident_type')">
-  <div class="col-md-3 reg-field-label">
-    <label>{{idl_fields.au.ident_type.label}}</label>
-    <img ng-show="field_doc.au.ident_type" 
-      ng-click="selected_field_doc=field_doc.au.ident_type"
-      src='[% DOC_IMG %]'></img>
-  </div>
+  [% draw_field_label('au', 'ident_type') %]
   <div class="col-md-3 reg-field-input">
-    <div class="btn-group" dropdown>
-      <button type="button" class="btn btn-default dropdown-toggle">
-        <span style="padding-right: 5px;">
-          {{patron.ident_type.name() || "[% l('Primary Ident Type') %]"}}
-        </span>
-        <span class="caret"></span>
-      </button>
-      <ul class="dropdown-menu">
-        <li ng-repeat="type in ident_types">
-          <a href ng-click="patron.ident_type = type; field_modified()">
-            {{type.name()}}
-          </a>
-        </li>
-      </ul>
-    </div>
+    <select 
+      class="form-control" 
+      ng-model="patron.ident_type"
+      ng-required="field_required('au', 'ident_type')"
+      ng-blur="handle_field_changed(patron, 'ident_type')"
+      ng-options="type.name() for type in ident_types track by type.id()">
+    </select>
   </div>
 </div>
 
+<!-- IDENT_VALUE -->
+
+<div class="row reg-field-row" ng-show="show_field('au.ident_value')">
+  [% draw_field_label('au', 'ident_value') %]
+  [% draw_form_input('au', 'ident_value') %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'ident_value') %]
+  </div>
+</div>
+
+<!-- IDENT_VALUE2 -->
+<div class="row reg-field-row" ng-show="show_field('au.ident_value2')">
+  [% draw_field_label('au', 'ident_value2') %]
+  [% draw_form_input('au', 'ident_value2') %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'ident_value2') %]
+  </div>
+</div>
 
-[% formfield('au', 'ident_value') %]
-[% formfield('au', 'ident_value2') %]
-[% formfield('au', 'email', '', 'email') %]
-[% formfield('au', 'day_phone') %]
-[% formfield('au', 'evening_phone') %]
-[% formfield('au', 'other_phone') %]
+
+<!-- EMAIL -->
+<div class="row reg-field-row" ng-show="show_field('au.email')">
+  [% draw_field_label('au', 'email') %]
+  [% draw_form_input('au', 'email', '', 'email') %]
+  <div class="col-md-6 patron-reg-example">
+    <button ng-show="patron.email && !patron.isnew" 
+      class="btn btn-default" 
+      ng-click="invalidate_field('email')">[% l('Invalidate') %]</button>
+    <span ng-if="org_settings['ui.patron.edit.au.email.example']">
+      [% l('Example: [_1]',
+        "{{org_settings['ui.patron.edit.au.email.example']}}") %]
+    </span>
+  </div>
+</div>
+
+<!-- DAY_PHONE -->
+
+<div class="row reg-field-row" ng-show="show_field('au.day_phone')">
+  [% draw_field_label('au', 'day_phone') %]
+  [% draw_form_input('au', 'day_phone') %]
+  <div class="col-md-6 patron-reg-example">
+    <button ng-show="patron.day_phone && !patron.isnew" 
+        class="btn btn-default" 
+        ng-click="invalidate_field('day_phone')">[% l('Invalidate') %]</button>
+    [% draw_example_text('au', 'day_phone') %]
+    <!-- phones have a fall-through example strings -->
+    <span ng-if="!org_settings['ui.patron.edit.au.day_phone.example'] && org_settings['ui.patron.edit.phone.example']">
+      [% l('Example: [_1]', 
+        "{{org_settings['ui.patron.edit.phone.example']}}") %]
+    </span>
+  </div>
+</div>
+
+<!-- EVENING_PHONE -->
+
+<div class="row reg-field-row" ng-show="show_field('au.evening_phone')">
+  [% draw_field_label('au', 'evening_phone') %]
+  [% draw_form_input('au', 'evening_phone') %]
+  <div class="col-md-6 patron-reg-example">
+    <button ng-show="patron.evening_phone && !patron.isnew" 
+        class="btn btn-default" 
+        ng-click="invalidate_field('evening_phone')">[% l('Invalidate') %]</button>
+    [% draw_example_text('au', 'evening_phone') %]
+    <!-- phones have a fall-through example strings -->
+    <span ng-if="!org_settings['ui.patron.edit.au.evening_phone.example'] && org_settings['ui.patron.edit.phone.example']">
+      [% l('Example: [_1]', 
+        "{{org_settings['ui.patron.edit.phone.example']}}") %]
+    </span>
+  </div>
+</div>
+
+<!-- OTHER_PHONE -->
+
+<div class="row reg-field-row" ng-show="show_field('au.other_phone')">
+  [% draw_field_label('au', 'other_phone') %]
+  [% draw_form_input('au', 'other_phone') %]
+  <div class="col-md-6 patron-reg-example">
+    <button ng-show="patron.other_phone && !patron.isnew" 
+        class="btn btn-default" 
+        ng-click="invalidate_field('other_phone')">[% l('Invalidate') %]</button>
+    [% draw_example_text('au', 'other_phone') %]
+    <!-- phones have a fall-through example strings -->
+    <span ng-if="!org_settings['ui.patron.edit.au.other_phone.example'] && org_settings['ui.patron.edit.phone.example']">
+      [% l('Example: [_1]', 
+        "{{org_settings['ui.patron.edit.phone.example']}}") %]
+    </span>
+  </div>
+</div>
 
 <!-- home org unit selector -->
 
 <div class="row reg-field-row" ng-show="show_field('au.home_ou')">
-  <div class="col-md-3 reg-field-label">
-    <label>{{idl_fields.au.home_ou.label}}</label>
-    <img ng-show="field_doc.au.home_ou" 
-      ng-click="selected_field_doc=field_doc.au.home_ou"
-      src='[% DOC_IMG %]'></img>
-    </div>
-    <div class="col-md-3 reg-field-input">
-      <eg-org-selector selected="patron.home_ou" onchange="field_modified">
-      </eg-org-selector>
+  [% draw_field_label('au', 'home_ou') %]
+  <div class="col-md-3 reg-field-input">
+    <eg-org-selector 
+      selected="patron.home_ou" 
+      onchange="handle_home_org_changed"
+      disable-test="disable_home_org">
+    </eg-org-selector>
   </div>
 </div>
 
 <!-- profile selector -->
 
 <div class="row reg-field-row" ng-show="show_field('au.profile')">
-  <div class="col-md-3 reg-field-label">
-    <label>{{idl_fields.au.profile.label}}</label>
-    <img ng-show="field_doc.au.profile" 
-      ng-click="selected_field_doc=field_doc.au.profile"
-      src='[% DOC_IMG %]'></img>
-  </div>
+  [% draw_field_label('au', 'profile') %]
   <div class="col-md-3 reg-field-input">
     <div class="btn-group" dropdown>
-      <button type="button" class="btn btn-default dropdown-toggle">
+      <button type="button" class="btn btn-default dropdown-toggle"
+          ng-class="{'ng-invalid' : invalid_profile()}">
         <span style="padding-right: 5px;">
           {{patron.profile.name() || "[% l('Profile Group') %]"}}
         </span>
         <span class="caret"></span>
       </button>
       <ul class="dropdown-menu">
-        <li ng-repeat="grp in edit_profiles">
+        <li ng-repeat="grp in edit_profiles" 
+          ng-class="{disabled : grp.usergroup() == 'f'}">
           <a href 
             style="padding-left: {{pgt_depth(grp) * 10 + 5}}px"
             ng-click="set_profile(grp)">{{grp.name()}}</a>
     </div>
   </div>
   <div class="col-md-3">
-    <button class="btn btn-default" ng-disabled="!has_group_link_perm"
+    <button class="btn btn-default" ng-disabled="!perms.CREATE_USER_GROUP_LINK"
       ng-click="secondary_groups_dialog()">[% l('Secondary Groups') %]</button>
   </div> 
 </div>
 
 <div class="row reg-field-row" ng-show="show_field('au.expire_date')">
-  <div class="col-md-3 reg-field-label">
-  <label>{{idl_fields.au.expire_date.label}}</label>
-    <img ng-show="field_doc.au.expire_date" 
-    ng-click="selected_field_doc=field_doc.au.expire_date"
-    src='[% DOC_IMG %]'></img>
-  </div>
+  [% draw_field_label('au', 'expire_date') %]
   <div class="col-md-3 reg-field-input">
     <input eg-date-input 
-      ng-change="field_modified()" 
+      ng-blur="handle_field_changed(patron, 'expire_date')"
       class="form-control" ng-model="patron.expire_date"/>
   </div>
   <div class="col-md-3">
 <!-- net_access_level -->
 
 <div class="row reg-field-row" ng-show="show_field('au.net_access_level')">
-  <div class="col-md-3 reg-field-label">
-    <label>{{idl_fields.au.net_access_level.label}}</label>
-    <img ng-show="field_doc.au.net_access_level" 
-      ng-click="selected_field_doc=field_doc.au.net_access_level"
-      src='[% DOC_IMG %]'></img>
-  </div>
+  [% draw_field_label('au', 'net_access_level') %]
   <div class="col-md-3 reg-field-input">
-    <div class="btn-group" dropdown>
-      <button type="button" class="btn btn-default dropdown-toggle">
-        <span style="padding-right: 5px;">
-          {{patron.net_access_level.name() || "[% l('Net Access Level') %]"}}
-        </span>
-        <span class="caret"></span>
-      </button>
-      <ul class="dropdown-menu">
-        <li ng-repeat="level in net_access_levels">
-          <a href 
-            ng-click="patron.net_access_level = level">{{level.name()}}</a>
-        </li>
-      </ul>
-    </div>
+    <select 
+      class="form-control" 
+      ng-model="patron.net_access_level"
+      ng-required="field_required('au', 'net_access_level')"
+      ng-blur="handle_field_changed(patron, 'net_access_level')"
+      ng-options="level.name() for level in net_access_levels track by level.id()">
+    </select>
+  </div>
+</div>
+
+<!-- ACTIVE -->
+
+<div class="row reg-field-row" ng-show="show_field('au.active')">
+  [% draw_field_label('au', 'active') %]
+  [% draw_form_input('au', 'active', '', 'checkbox') %]
+</div>
+
+<!-- BARRED -->
+
+<div class="row reg-field-row" ng-show="show_field('au.barred')">
+  [% draw_field_label('au', 'barred') %]
+  [% draw_form_input('au', 'barred', '', 'checkbox') %]
+</div>
+
+<!-- MASTER_ACCOUNT -->
+
+<div class="row reg-field-row" ng-show="show_field('au.master_account')">
+  [% draw_field_label('au', 'master_account') %]
+  [% draw_form_input('au', 'master_account', '', 'checkbox') %]
+</div>
+
+<!-- CLAIMS_RETURNED_COUNT -->
+
+<div class="row reg-field-row" ng-show="show_field('au.claims_returned_count')">
+  [% draw_field_label('au', 'claims_returned_count') %]
+  [% draw_form_input('au', 'claims_returned_count', 
+    '', 'number', '!perms.UPDATE_PATRON_CLAIM_RETURN_COUNT') %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'claims_returned_count') %]
+  </div>
+</div>
+
+<!-- CLAIMS_NEVER_CHECKED_OUT_COUNT -->
+
+<div class="row reg-field-row" ng-show="show_field('au.claims_never_checked_out_count')">
+  [% draw_field_label('au', 'claims_never_checked_out_count') %]
+  [% draw_form_input('au', 'claims_never_checked_out_count',
+    '', 'number', '!perms.UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT') %]
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'claims_never_checked_out_count') %]
   </div>
 </div>
 
-[% formfield('au', 'active', '', 'checkbox') %]
-[% formfield('au', 'barred', '', 'checkbox') %]
-[% formfield('au', 'master_account', '', 'checkbox') %]
-[% formfield('au', 'claims_returned_count', '', 'number') %]
-[% formfield('au', 'claims_never_checked_out_count', '', 'number') %]
-[% formfield('au', 'alert_message') %]
+<!-- ALERT_MESSAGE -->
+
+<div class="row reg-field-row" ng-show="show_field('au.alert_message')">
+  [% draw_field_label('au', 'alert_message') %]
+  <div class="col-md-3 reg-field-input">
+    <textarea 
+      class="form-control" 
+      ng-model="patron.alert_message"
+      ng-pattern="field_pattern('au', 'alert_message')"
+      ng-change="field_modified()" 
+      ng-blur="handle_field_changed(patron, 'alert_message')">
+    </textarea>
+  </div>
+  <div class="col-md-6 patron-reg-example">
+    [% draw_example_text('au', 'alert_message') %]
+  </div>
+</div>
 
 <div class="alert alert-success row" role="alert">
   <div class="col-md-6">[% l('User Settings') %]</div>
   </div>
 </div>
 
+<!-- TODO: Add circ.collections.exempt to master SQL seed data -->
+<div class="row reg-field-row" 
+  ng-if="user_setting_types['circ.collections.exempt']">
+  <div class="col-md-3 reg-field-label">
+    <label>{{user_setting_types['circ.collections.exempt'].label()}}</label>
+  </div>
+  <div class="col-md-3 reg-field-input">
+    <div class='checkbox'>
+      <input 
+        type='checkbox' 
+        ng-change="field_modified()" 
+        ng-disabled="!perms.UPDATE_PATRON_COLLECTIONS_EXEMPT"
+        ng-model="user_settings['circ.collections.exempt']"/>
+    </div>
+  </div>
+</div>
+
 <div class="row reg-field-row">
   <div class="col-md-3 reg-field-label">
     <label>[% l('Holds Notices') %]</label>
       </div>
   </div>
 
-  [% formfield('aua', 'address_type', 'addresses[$index]') %]
-  [% formfield('aua', 'post_code', 'addresses[$index]') %]
-  [% formfield('aua', 'street1', 'addresses[$index]') %]
-  [% formfield('aua', 'street2', 'addresses[$index]') %]
-  [% formfield('aua', 'city', 'addresses[$index]') %]
-  [% formfield('aua', 'county', 'addresses[$index]') %]
-  [% formfield('aua', 'state', 'addresses[$index]') %]
-  [% formfield('aua', 'country', 'addresses[$index]') %]
-  [% formfield('aua', 'valid', 'addresses[$index]', 'checkbox') %]
-  [% formfield('aua', 'within_city_limits', 'addresses[$index]', 'checkbox') %]
+  <!-- ADDRESS_TYPE -->
+  <div class="row reg-field-row" ng-show="show_field('aua.address_type')">
+    [% draw_field_label('aua', 'address_type') %]
+    [% draw_form_input('aua', 'address_type', 'addresses[$index]') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'address_type') %]
+    </div>
+  </div>
+
+  <!-- POST_CODE -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.post_code')">
+    [% draw_field_label('aua', 'post_code') %]
+    [% draw_form_input('aua', 'post_code', 'addresses[$index]') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'post_code') %]
+    </div>
+  </div>
+
+  <!-- STREET1 -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.street1')">
+    [% draw_field_label('aua', 'street1') %]
+    [% draw_form_input('aua', 'street1', 'addresses[$index]') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'street1') %]
+    </div>
+  </div>
+
+  <!-- STREET2 -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.street2')">
+    [% draw_field_label('aua', 'street2') %]
+    [% draw_form_input('aua', 'street2', 'addresses[$index]') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'street2') %]
+    </div>
+  </div>
+
+  <!-- CITY -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.city')">
+    [% draw_field_label('aua', 'city') %]
+    [% draw_form_input('aua', 'city', 'addresses[$index]') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'city') %]
+    </div>
+  </div>
+
+  <!-- COUNTY -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.county')">
+    [% draw_field_label('aua', 'county') %]
+    [% draw_form_input('aua', 'county', 'addresses[$index]') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'county') %]
+    </div>
+  </div>
+
+  <!-- STATE -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.state')">
+    [% draw_field_label('aua', 'state') %]
+    [% draw_form_input('aua', 'state', 'addresses[$index]') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'state') %]
+    </div>
+  </div>
+
+  <!-- COUNTRY -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.country')">
+    [% draw_field_label('aua', 'country') %]
+    [% draw_form_input('aua', 'country', 'addresses[$index]') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'country') %]
+    </div>
+  </div>
+
+  <!-- VALID -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.valid')">
+    [% draw_field_label('aua', 'valid') %]
+    [% draw_form_input('aua', 'valid', 'addresses[$index]', 'checkbox') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'valid') %]
+    </div>
+  </div>
+
+  <!-- WITHIN_CITY_LIMITS -->
+
+  <div class="row reg-field-row" ng-show="show_field('aua.within_city_limits')">
+    [% draw_field_label('aua', 'within_city_limits') %]
+    [% draw_form_input('aua', 'within_city_limits', 'addresses[$index]', 'checkbox') %]
+    <div class="col-md-6 patron-reg-example">
+      [% draw_example_text('aua', 'within_city_limits') %]
+    </div>
+  </div>
 
   <div class="row" ng-if="$last">
     <button type="button" ng-click="new_address()" 
index 4ef6b32..5ba4652 100644 (file)
       <div class="row" ng-repeat="card in args.cards">
         <div class="col-md-4">{{card.barcode}}</div>
         <div class="col-md-4">
-          <input type='checkbox' ng-model='card.active'/>
+          <input type='checkbox' ng-model='card.active' 
+            ng-disabled="!perms.UPDATE_PATRON_ACTIVE_CARD"/>
         </div>
         <div class="col-md-4">
-          <input type='radio' name='primary' value='on' ng-model='card._primary'/>
+          <input type='radio' name='primary' value='on' 
+            ng-model='card._primary'
+            ng-disabled="!perms.UPDATE_PATRON_PRIMARY_CARD"/>
         </div>
       </div>
     </div>
     <div class="modal-footer">
-      <input type="submit" class="btn btn-primary" value="[% l('Apply Changes') %]"/>
+      <input type="submit" class="btn btn-primary" value="[% l('Apply Changes') %]"
+        ng-disabled="!perms.UPDATE_PATRON_PRIMARY_CARD && !perms.UPDATE_PATRON_ACTIVE_CARD"/>
       <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
     </div>
   </div> <!-- modal-content -->
index e381543..c676744 100644 (file)
@@ -151,6 +151,21 @@ but the ones I'm finding aren't quite cutting it..*/
   font-weight: bold;
 }
 
+/* Bootstrap alert panes are too stylized/padded/etc. in this case,
+ * but consider revisiting. */
+.patron-reg-validation-alert {
+  font-weight: bold;
+  color: red;
+}
+
+/* Angular applies these classes based on the field's 
+ * required and pattern settings */
+#patron-reg-container .ng-invalid,
+#patron-reg-container .ng-invalid-required {
+  background-color: yellow;
+  color: red;
+}
+
 /* -- end patron registration -- */
 
 [%# 
index 1e78455..1749f32 100644 (file)
@@ -109,6 +109,13 @@ angular.module('egCoreMod')
         return last + ', ' + first + (middle ? ' ' + middle : '');
     }
 
+    service.check_dupe_username = function(usrname) {
+        return egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.username.exists',
+            egCore.auth.token(), usrname);
+    }
+
     //service.check_grp_app_perm = function(grp_id) {
 
     // determine which user groups our user is not allowed to modify
@@ -148,9 +155,31 @@ angular.module('egCoreMod')
         );
     }
 
-    service.has_group_link_perms = function(org_id) {
-        return egCore.perm.hasPermAt('CREATE_USER_GROUP_LINK', true)
-        .then(function(p) { return p.indexOf(org_id) > -1; });
+    // resolves to a hash of perm-name => boolean value indicating
+    // wether the user has the permission at org_id.
+    service.has_perms_for_org = function(org_id) {
+
+        var perms_needed = [
+            'UPDATE_USER',
+            'CREATE_USER',
+            'CREATE_USER_GROUP_LINK', 
+            'UPDATE_PATRON_COLLECTIONS_EXEMPT',
+            'UPDATE_PATRON_CLAIM_RETURN_COUNT',
+            'UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT',
+            'UPDATE_PATRON_ACTIVE_CARD',
+            'UPDATE_PATRON_PRIMARY_CARD'
+        ];
+
+        return egCore.perm.hasPermAt(perms_needed, true)
+        .then(function(perm_map) {
+
+            angular.forEach(perms_needed, function(perm) {
+                perm_map[perm] = 
+                    Boolean(perm_map[perm].indexOf(org_id) > -1);
+            });
+
+            return perm_map;
+        });
     }
 
     service.get_surveys = function() {
@@ -451,15 +480,14 @@ angular.module('egCoreMod')
     service.dupe_patron_search = function(patron, type, value) {
         var search;
 
-        console.log('Dupe search called with "' + 
-            type +"' and value " + value);
+        console.log('Dupe search called with "'+ type +'" and value '+ value);
 
         switch (type) {
 
             case 'name':
                 var fname = patron.first_given_name;   
                 var lname = patron.family_name;   
-                if (!(fname && lname)) return;
+                if (!(fname && lname)) return $q.when({count:0});
                 search = {
                     first_given_name : {value : fname, group : 0},
                     family_name : {value : lname, group : 0}
@@ -574,7 +602,7 @@ angular.module('egCoreMod')
             _is_mailing : true,
             _is_billing : true,
             within_city_limits : false,
-            stat_cat_entries : []
+            country : service.org_settings['ui.patron.default_country'],
         };
 
         var card = {
@@ -590,6 +618,7 @@ angular.module('egCoreMod')
             card : card,
             cards : [card],
             home_ou : egCore.org.get(egCore.auth.user().ws_ou()),
+            stat_cat_entries : [],
             addresses : [addr]
         };
 
@@ -943,11 +972,49 @@ angular.module('egCoreMod')
             'open-ils.actor.patron.settings.update',
             egCore.auth.token(), new_user.id(), settings
         ).then(function(resp) {
-            console.log('settings returned ' + resp);
             return resp;
         });
     }
 
+    // Applies field-specific validation regex's from org settings 
+    // to form fields.  Be careful not remove any pattern data we
+    // are not explicitly over-writing in the provided patterns obj.
+    service.set_field_patterns = function(patterns) {
+        if (service.org_settings['opac.username_regex']) {
+            patterns.au.usrname = 
+                new RegExp(service.org_settings['opac.username_regex']);
+        }
+
+        if (service.org_settings['opac.barcode_regex']) {
+            patterns.ac.barcode = 
+                new RegExp(service.org_settings['opac.barcode_regex']);
+        }
+
+        if (service.org_settings['global.password_regex']) {
+            patterns.au.passwd = 
+                new RegExp(service.org_settings['global.password_regex']);
+        }
+
+        var phone_reg = service.org_settings['ui.patron.edit.phone.regex'];
+        if (phone_reg) {
+            // apply generic phone regex first, replace below as needed.
+            patterns.au.day_phone = new RegExp(phone_reg);
+            patterns.au.evening_phone = new RegExp(phone_reg);
+            patterns.au.other_phone = new RegExp(phone_reg);
+        }
+
+        // the remaining patterns fit a well-known key name pattern
+
+        angular.forEach(service.org_settings, function(val, key) {
+            if (!val) return;
+            var parts = key.match(/ui.patron.edit\.(\w+)\.(\w+)\.regex/);
+            if (!parts) return;
+            var cls = parts[1];
+            var name = parts[2];
+            patterns[cls][name] = new RegExp(val);
+        });
+    }
+
     return service;
 }]);
 
@@ -967,6 +1034,10 @@ function PatronRegCtrl($scope, $routeParams,
     $scope.focus_bc = !Boolean($scope.patron_id);
     $scope.dupe_counts = {};
 
+    // map of perm name to true/false for perms the logged in user
+    // has at the currently selected patron home org unit.
+    $scope.perms = {};
+
     if (!$scope.edit_passthru) {
         // in edit more, scope.edit_passthru is delivered to us by
         // the enclosing controller.  In register mode, there is 
@@ -976,15 +1047,6 @@ function PatronRegCtrl($scope, $routeParams,
 
     // 0=all, 1=suggested, 2=all
     $scope.edit_passthru.vis_level = 0; 
-    // TODO: add save/clone handlers here
-
-    $scope.field_modified = function() {
-        // Call attach with every field change, regardless of whether
-        // it's been called before.  This will allow for re-attach after
-        // the user clicks through the unload warning. egUnloadPrompt
-        // will ensure we only attach once.
-        egUnloadPrompt.attach($scope);
-    }
 
     // Apply default values for new patrons during initial registration
     // prs is shorthand for patronSvc
@@ -1019,13 +1081,21 @@ function PatronRegCtrl($scope, $routeParams,
         }
     }
 
-    function handle_home_org_changed() {
-        org_id = $scope.patron.home_ou.id();
-
-        patronRegSvc.has_group_link_perms(org_id)
-        .then(function(bool) {$scope.has_group_link_perm = bool});
+    // A null or undefined pattern leads to exceptions.  Before the
+    // patterns are loaded from the server, default all patterns
+    // to an innocuous regex.  To avoid re-creating numerous
+    // RegExp objects, cache the stub RegExp after initial creation.
+    // note: angular docs say ng-pattern accepts a regexp or string,
+    // but as of writing, it only works with a regexp object.
+    // (Likely an angular 1.2 vs. 1.4 issue).
+    var field_patterns = {au : {}, ac : {}, aua : {}};
+    $scope.field_pattern = function(cls, field) { 
+        if (!field_patterns[cls][field])
+            field_patterns[cls][field] = new RegExp('.*');
+        return field_patterns[cls][field];
     }
 
+    // Main page load function.  Kicks off tab init and data loading.
     $q.all([
 
         $scope.initTab ? // initTab comes from patron app
@@ -1063,7 +1133,7 @@ function PatronRegCtrl($scope, $routeParams,
         });
 
         extract_hold_notify();
-        handle_home_org_changed();
+        $scope.handle_home_org_changed();
 
         if ($scope.org_settings['ui.patron.edit.default_suggested'])
             $scope.edit_passthru.vis_level = 1;
@@ -1073,6 +1143,7 @@ function PatronRegCtrl($scope, $routeParams,
 
         $scope.page_data_loaded = true;
 
+        prs.set_field_patterns(field_patterns);
     });
 
     // update the currently displayed field documentation
@@ -1095,26 +1166,28 @@ function PatronRegCtrl($scope, $routeParams,
     };
 
     // field visibility cache.  Some fields are universally required.
+    // 3 == value universally required
+    // 2 == field is visible by default
+    // 1 == field is suggested by default
     var field_visibility = {
-        'ac.barcode' : 2,
-        'au.usrname' : 2,
-        'au.passwd' :  2,
-        // TODO passwd2 2,
-        'au.first_given_name' : 2,
-        'au.family_name' : 2,
-        'au.ident_type' : 2,
-        'au.home_ou' : 2,
-        'au.profile' : 2,
-        'au.expire_date' : 2,
-        'au.net_access_level' : 2,
-        'aua.address_type' : 2,
-        'aua.post_code' : 2,
-        'aua.street1' : 2,
+        'ac.barcode' : 3,
+        'au.usrname' : 3,
+        'au.passwd' :  3,
+        'au.first_given_name' : 3,
+        'au.family_name' : 3,
+        'au.ident_type' : 3,
+        'au.home_ou' : 3,
+        'au.profile' : 3,
+        'au.expire_date' : 3,
+        'au.net_access_level' : 3,
+        'aua.address_type' : 3,
+        'aua.post_code' : 3,
+        'aua.street1' : 3,
         'aua.street2' : 2,
-        'aua.city' : 2,
+        'aua.city' : 3,
         'aua.county' : 2,
         'aua.state' : 2,
-        'aua.country' : 2,
+        'aua.country' : 3,
         'aua.valid' : 2,
         'aua.within_city_limits' : 2,
         'stat_cats' : 1,
@@ -1136,7 +1209,7 @@ function PatronRegCtrl($scope, $routeParams,
             var sug_set = 'ui.patron.edit.' + field_key + '.suggest';
 
             if ($scope.org_settings[req_set]) {
-                field_visibility[field_key] = 2;
+                field_visibility[field_key] = 3;
             } else if ($scope.org_settings[sho_set]) {
                 field_visibility[field_key] = 2;
             } else if ($scope.org_settings[sug_set]) {
@@ -1149,6 +1222,18 @@ function PatronRegCtrl($scope, $routeParams,
         return field_visibility[field_key] >= $scope.edit_passthru.vis_level;
     }
 
+    // See $scope.show_field().
+    // A field with visbility level 3 means it's required.
+    $scope.field_required = function(cls, field) {
+
+        // Value in the password field is not required
+        // for existing patrons.
+        if (field == 'passwd' && $scope.patron && !$scope.patron.isnew) 
+          return false;
+
+        return (field_visibility[cls + '.' + field] == 3);
+    }
+
     // generates a random 4-digit password
     $scope.generate_password = function() {
         $scope.patron.passwd = Math.floor(Math.random()*9000) + 1000;
@@ -1170,6 +1255,14 @@ function PatronRegCtrl($scope, $routeParams,
         $scope.field_modified();
     }
 
+    $scope.invalid_profile = function() {
+        return !(
+            $scope.patron && 
+            $scope.patron.profile && 
+            $scope.patron.profile.usergroup() == 't'
+        );
+    }
+
     $scope.new_address = function() {
         var addr = egCore.idl.toHash(new egCore.idl.aua());
         patronRegSvc.ingest_address($scope.patron, addr);
@@ -1177,6 +1270,7 @@ function PatronRegCtrl($scope, $routeParams,
         addr.isnew = true;
         addr.valid = true;
         addr.within_city_limits = true;
+        addr.country = $scope.org_settings['ui.patron.default_country'];
         $scope.patron.addresses.push(addr);
     }
 
@@ -1234,12 +1328,14 @@ function PatronRegCtrl($scope, $routeParams,
 
     $scope.barcode_changed = function(bc) {
         if (!bc) return;
+        $scope.dupe_barcode = false;
         egCore.net.request(
             'open-ils.actor',
             'open-ils.actor.barcode.exists',
             egCore.auth.token(), bc
         ).then(function(resp) {
             if (resp == '1') {
+                $scope.dupe_barcode = true;
                 console.log('duplicate barcode detected: ' + bc);
                 // DUPLICATE CARD
             } else {
@@ -1254,10 +1350,11 @@ function PatronRegCtrl($scope, $routeParams,
         $modal.open({
             templateUrl: './circ/patron/t_patron_cards_dialog',
             controller: 
-                   ['$scope','$modalInstance','cards',
-            function($scope , $modalInstance , cards) {
+                   ['$scope','$modalInstance','cards', 'perms',
+            function($scope , $modalInstance , cards, perms) {
                 // scope here is the modal-level scope
                 $scope.args = {cards : cards};
+                $scope.perms = perms;
                 $scope.ok = function() { $modalInstance.close($scope.args) }
                 $scope.cancel = function () { $modalInstance.dismiss() }
             }],
@@ -1265,6 +1362,9 @@ function PatronRegCtrl($scope, $routeParams,
                 cards : function() {
                     // scope here is the controller-level scope
                     return $scope.patron.cards;
+                },
+                perms : function() {
+                    return $scope.perms;
                 }
             }
         }).result.then(
@@ -1393,16 +1493,148 @@ function PatronRegCtrl($scope, $routeParams,
         patronRegSvc.invalidate_field($scope.patron, field);
     }
 
+
     $scope.dupe_value_changed = function(type, value) {
         $scope.dupe_counts[type] = 0;
         patronRegSvc.dupe_patron_search($scope.patron, type, value)
         .then(function(res) {
             $scope.dupe_counts[type] = res.count;
-            $scope.dupe_search_encoded = 
-                encodeURIComponent(js2JSON(res.search));
+            if (res.count) {
+                $scope.dupe_search_encoded = 
+                    encodeURIComponent(js2JSON(res.search));
+            } else {
+                $scope.dupe_search_encoded = '';
+            }
+        });
+    }
+
+    $scope.handle_home_org_changed = function() {
+        org_id = $scope.patron.home_ou.id();
+        patronRegSvc.has_perms_for_org(org_id).then(function(map) {
+            angular.forEach(map, function(v, k) { $scope.perms[k] = v });
         });
     }
 
+    // This is called with every character typed in a form field,
+    // since that's the only way to gaurantee something has changed.
+    // See handle_field_changed for ng-change vs. ng-blur.
+    $scope.field_modified = function() {
+        // Call attach with every field change, regardless of whether
+        // it's been called before.  This will allow for re-attach after
+        // the user clicks through the unload warning. egUnloadPrompt
+        // will ensure we only attach once.
+        egUnloadPrompt.attach($scope);
+    }
+
+    // obj could be the patron, an address, etc.
+    // This is called any time a form field achieves then loses focus.
+    // It does not necessarily mean the field has changed.
+    // The alternative is ng-change, but it's called with each character
+    // typed, which would be overkill for many of the actions called here.
+    $scope.handle_field_changed = function(obj, field_name) {
+        var cls = obj.classname; // set by egIdl
+        var value = obj[field_name];
+
+        console.log('changing field ' + field_name + ' to ' + value);
+
+        switch (field_name) {
+            case 'day_phone' : 
+                if ($scope.patron.day_phone && 
+                    $scope.patron.isnew && 
+                    $scope.org_settings['patron.password.use_phone']) {
+                    $scope.patron.passwd = phone.substr(-4);
+                }
+            case 'evening_phone' : 
+            case 'other_phone' : 
+                $scope.dupe_value_changed('phone', value);
+                break;
+
+            case 'ident_value':
+            case 'ident_value2':
+                $scope.dupe_value_changed('ident', value);
+                break;
+
+            case 'first_given_name':
+            case 'family_name':
+                $scope.dupe_value_changed('name', value);
+                break;
+
+            case 'email':
+                $scope.dupe_value_changed('email', value);
+                break;
+
+            case 'street1':
+            case 'street2':
+            case 'city':
+                // dupe search on address wants the address object as the value.
+                $scope.dupe_value_changed('address', obj);
+                break;
+
+            case 'post_code':
+                $scope.post_code_changed(obj);
+                break;
+
+            case 'usrname':
+                patronRegSvc.check_dupe_username(value)
+                .then(function(yes) {$scope.dupe_username = Boolean(yes)});
+                break;
+
+            case 'barcode':
+                // TODO: finish barcode_changed handler.
+                $scope.barcode_changed(value);
+                break;
+
+            case 'dob':
+                maintain_juvenile_flag();
+                break;
+        }
+    }
+
+    // patron.juvenile is set to true if the user was born after
+    function maintain_juvenile_flag() {
+        if ( !($scope.patron && $scope.patron.dob) ) return;
+
+        var juv_interval = 
+            $scope.org_settings['global.juvenile_age_threshold'] 
+            || '18 years';
+
+        var base = new Date();
+
+        base.setTime(base.getTime() - 
+            Number(egCore.date.intervalToSeconds(juv_interval) + '000'));
+
+        $scope.patron.juvenile = ($scope.patron.dob > base);
+    }
+
+    // returns true (disable) for orgs that cannot have users.
+    $scope.disable_home_org = function(org_id) {
+        if (!org_id) return;
+        var org = egCore.org.get(org_id);
+        return (
+            org &&
+            org.ou_type() &&
+            org.ou_type().can_have_users() == 'f'
+        );
+    }
+
+    // Returns true if any input elements are tagged as invalid
+    $scope.edit_passthru.has_invalid_fields = function() {
+        return $('#patron-reg-container .ng-invalid').length > 0;
+    }
+
+    // Returns true if the Save and Save & Clone buttons should be disabled.
+    $scope.edit_passthru.hide_save_actions = function() {
+        var can_save = $scope.patron.isnew ?
+            $scope.perms.CREATE_USER : $scope.perms.UPDATE_USER;
+
+        return (
+            !can_save ||
+            $scope.dupe_username ||
+            $scope.dupe_barcode ||
+            $scope.edit_passthru.has_invalid_fields()
+        );
+    }
+
     $scope.edit_passthru.save = function(save_args) {
         if (!save_args) save_args = {};