APIv4 - Add `nullable` property to getFields; improve SearchKit editable UX
authorColeman Watts <coleman@civicrm.org>
Mon, 3 Jan 2022 16:48:37 +0000 (11:48 -0500)
committerColeman Watts <coleman@civicrm.org>
Wed, 26 Jan 2022 15:00:28 +0000 (10:00 -0500)
Unlike the 'required' field property, which only determines if the API requires a value to Create,
the 'nullable' property tells a UI whether a field is allowed to be set to NULL in Create OR Update.

SearchKit uses this property during in-place edit and bulk edit operations to determine whether a
field can be left blank.

20 files changed:
Civi/Api4/Generic/BasicGetFieldsAction.php
Civi/Api4/Service/Spec/FieldSpec.php
Civi/Api4/Service/Spec/SpecFormatter.php
Civi/Api4/Service/Spec/SpecGatherer.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js
ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.html
ext/search_kit/ang/crmSearchTasks/crmSearchInput/date.html
ext/search_kit/ang/crmSearchTasks/crmSearchInput/float.html
ext/search_kit/ang/crmSearchTasks/crmSearchInput/integer.html
ext/search_kit/ang/crmSearchTasks/crmSearchInput/select.html
ext/search_kit/ang/crmSearchTasks/crmSearchInput/text.html
ext/search_kit/ang/crmSearchTasks/crmSearchTaskUpdate.ctrl.js
ext/search_kit/ang/crmSearchTasks/crmSearchTaskUpdate.html
ext/search_kit/ang/crmSearchTasks/crmSearchTasks.component.js
ext/search_kit/css/crmSearchAdmin.css
ext/search_kit/css/crmSearchDisplay.css
tests/phpunit/api/v4/Action/BasicCustomFieldTest.php
tests/phpunit/api/v4/Action/GetFieldsTest.php

index 7bb18411efc4a117cfc3952279805887e8c7cd4c..483cf7353e7d6fedc93c5ffa60e79be9c0e73b01 100644 (file)
@@ -282,9 +282,16 @@ class BasicGetFieldsAction extends BasicGetAction {
       ],
       [
         'name' => 'required',
+        'description' => 'Is this field required when creating a new entity',
         'data_type' => 'Boolean',
         'default_value' => FALSE,
       ],
+      [
+        'name' => 'nullable',
+        'description' => 'Whether a null value is allowed in this field',
+        'data_type' => 'Boolean',
+        'default_value' => TRUE,
+      ],
       [
         'name' => 'required_if',
         'data_type' => 'String',
index e1da6ce9c15ffe7e97ff4e9937452303f6e6eef7..89f13864160fc7fae5c1dd3e1d671fd7b002dcb8 100644 (file)
@@ -63,6 +63,11 @@ class FieldSpec {
   /**
    * @var bool
    */
+  public $nullable = TRUE;
+
+  /**
+   * @var string
+   */
   public $requiredIf;
 
   /**
@@ -127,6 +132,24 @@ class FieldSpec {
     return $this->entity;
   }
 
+  /**
+   * @return bool
+   */
+  public function getNullable() {
+    return $this->nullable;
+  }
+
+  /**
+   * @param bool $nullable
+   *
+   * @return $this
+   */
+  public function setNullable(bool $nullable) {
+    $this->nullable = $nullable;
+
+    return $this;
+  }
+
   /**
    * @return bool
    */
@@ -146,14 +169,14 @@ class FieldSpec {
   }
 
   /**
-   * @return bool
+   * @return string
    */
   public function getRequiredIf() {
     return $this->requiredIf;
   }
 
   /**
-   * @param bool $requiredIf
+   * @param string $requiredIf
    *
    * @return $this
    */
index cee41e1ac1daea71ec7dabf8a88b3adf7f1717f4..058f83dea24fa5aaca86f7b6881636eb62ac72fa 100644 (file)
@@ -37,6 +37,7 @@ class SpecFormatter {
         $field->setTableName($data['custom_group_id.table_name']);
       }
       $field->setColumnName($data['column_name']);
+      $field->setNullable(empty($data['is_required']));
       $field->setCustomFieldId($data['id'] ?? NULL);
       $field->setCustomGroupName($data['custom_group_id.name']);
       $field->setTitle($data['label']);
@@ -58,7 +59,8 @@ class SpecFormatter {
       $field = new FieldSpec($name, $entity, $dataTypeName);
       $field->setType('Field');
       $field->setColumnName($name);
-      $field->setRequired(!empty($data['required']));
+      $field->setNullable(empty($data['required']));
+      $field->setRequired(!empty($data['required']) && empty($data['default']));
       $field->setTitle($data['title'] ?? NULL);
       $field->setLabel($data['html']['label'] ?? NULL);
       if (!empty($data['pseudoconstant'])) {
index c4f49b7c9b53afaf814f13a946059258032c32db..d166207bda1b4573150f0322ea154b147573f405 100644 (file)
@@ -97,9 +97,6 @@ class SpecGatherer {
       ) {
         continue;
       }
-      if ($action !== 'create' || isset($DAOField['default'])) {
-        $DAOField['required'] = FALSE;
-      }
       if ($DAOField['name'] == 'is_active' && empty($DAOField['default'])) {
         $DAOField['default'] = '1';
       }
index 8f5a98c72b038ac3f1681ca75685cfe65817e575..28c92d7e16afbf990bea42607d18150b36ef2d29 100644 (file)
@@ -497,7 +497,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
 
   /**
    * @param $key
-   * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, fk_entity: string, value_key: string, value_path: string, id_key: string, id_path: string}|null
+   * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, nullable: bool, fk_entity: string, value_key: string, value_path: string, id_key: string, id_path: string}|null
    */
   private function getEditableInfo($key) {
     [$key] = explode(':', $key);
@@ -520,6 +520,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
         'data_type' => $field['data_type'],
         'options' => !empty($field['options']),
         'serialize' => !empty($field['serialize']),
+        'nullable' => !empty($field['nullable']),
         'fk_entity' => $field['fk_entity'],
         'value_key' => $field['name'],
         'value_path' => $key,
index f811dd772fca844ff0de74321b5aa38f0c77ffb1..21f87cfff69b89fd3440159bbb196e2a35592fb0 100644 (file)
@@ -136,7 +136,7 @@ class Admin {
           $entity['links'] = array_values($links);
         }
         $getFields = civicrm_api4($entity['name'], 'getFields', [
-          'select' => ['name', 'title', 'label', 'description', 'type', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly', 'operators'],
+          'select' => ['name', 'title', 'label', 'description', 'type', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly', 'operators', 'nullable'],
           'where' => [['name', 'NOT IN', ['api_key', 'hash']]],
           'orderBy' => ['label'],
         ]);
index 052b9dcfbc5f7bc3f618b0229ec925465758c120..f205e4a3aa050cd801f226f5c6fb47f2e3a462bc 100644 (file)
@@ -29,6 +29,7 @@
           options: col.edit.options,
           fk_entity: col.edit.fk_entity,
           serialize: col.edit.serialize,
+          nullable: col.edit.nullable
         };
 
         $(document).on('keydown.crmSearchDisplayEditable', function(e) {
@@ -36,8 +37,6 @@
             $scope.$apply(function() {
               ctrl.cancel();
             });
-          } else if (e.key === 'Enter') {
-            $scope.$apply(ctrl.save);
           }
         });
 
@@ -71,7 +70,7 @@
           action: 'update',
           select: ['options'],
           loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
-          where: [['name', '=', ctrl.field.name]],
+          where: [['name', '=', ctrl.field.name]]
         }, 0).then(function(field) {
           ctrl.field.options = optionsCache[cacheKey] = field.options;
         });
index d0d8eca08f3cfb3caa1dbe3ec9f9ec2802455304..767c642294701081874194ed7587ced429b9a9fc 100644 (file)
@@ -1,9 +1,11 @@
-<crm-search-input class="form-inline" field="$ctrl.field" ng-model="$ctrl.value"></crm-search-input>
-<div class="form-inline crm-search-display-editable-buttons">
-  <button type="button" ng-click="$ctrl.save()" class="btn btn-xs btn-success">
-    <i class="crm-i fa-check"></i>
-  </button>
-  <button type="button" ng-click="$ctrl.cancel()" class="btn btn-xs btn-danger">
-    <i class="crm-i fa-times"></i>
-  </button>
-</div>
+<form name="crmSearchDisplayEditableForm">
+  <crm-search-input class="form-inline" field="$ctrl.field" ng-model="$ctrl.value"></crm-search-input>
+  <div class="form-inline crm-search-display-editable-buttons">
+    <button type="submit" ng-disabled="!crmSearchDisplayEditableForm.$valid" ng-click="$ctrl.save()" class="btn btn-xs btn-success">
+      <i class="crm-i fa-check"></i>
+    </button>
+    <button type="button" ng-click="$ctrl.cancel()" class="btn btn-xs btn-danger">
+      <i class="crm-i fa-times"></i>
+    </button>
+  </div>
+</form>
index 78e4d03d6d667df519cc7877d52f3b9c8177fadb..29d89573e58a2c3fc17b229dc34a0099d719643f 100644 (file)
@@ -10,8 +10,8 @@
   <div class="form-group" ng-switch="$ctrl.dateType">
 
     <div class="form-group" ng-switch-when="fixed">
-      <input class="form-control" crm-ui-datepicker="{time: $ctrl.field.data_type === 'Timestamp'}" ng-model="$ctrl.value" ng-if="!$ctrl.multi">
-      <input class="form-control" crm-multi-select-date ng-model="$ctrl.value" ng-if="$ctrl.multi">
+      <input class="form-control" crm-ui-datepicker="{time: $ctrl.field.data_type === 'Timestamp'}" ng-model="$ctrl.value" ng-required="!$ctrl.field.nullable" ng-if="!$ctrl.multi">
+      <input class="form-control" crm-multi-select-date ng-model="$ctrl.value" ng-required="!$ctrl.field.nullable" ng-if="$ctrl.multi">
     </div>
 
     <div class="form-group" ng-switch-when="range">
index 89b0079a789ea966a64877accde62b07ab7491f8..f34a1184a9f03e7339bbdb97bff8bc6bada09136 100644 (file)
@@ -1,6 +1,6 @@
 <div class="form-group" ng-if="!$ctrl.multi" >
-  <input type="number" step="any" class="form-control" ng-model="$ctrl.value">
+  <input type="number" step="any" class="form-control" ng-model="$ctrl.value" ng-required="!$ctrl.field.nullable">
 </div>
 <div class="form-group" ng-if="$ctrl.multi" >
-  <input class="form-control" ng-model="$ctrl.value" crm-ui-select="{multiple: true, tags: [], tokenSeparators: [','], formatNoMatches: ''}" ng-list>
+  <input class="form-control" ng-model="$ctrl.value" ng-required="!$ctrl.field.nullable" crm-ui-select="{multiple: true, tags: [], tokenSeparators: [','], formatNoMatches: ''}" ng-list>
 </div>
index 0c5e75c49abf80126c8430f87909fc5e3538d173..3f480c2b14e2d129b097a4b804205d0f1fe01434 100644 (file)
@@ -1,6 +1,6 @@
 <div class="form-group" ng-if="!$ctrl.multi" >
-  <input type="number" step="1" class="form-control" ng-model="$ctrl.value">
+  <input type="number" step="1" class="form-control" ng-model="$ctrl.value" ng-required="!$ctrl.field.nullable">
 </div>
 <div class="form-group" ng-if="$ctrl.multi" >
-  <input class="form-control" ng-model="$ctrl.value" crm-ui-select="{multiple: true, tags: [], tokenSeparators: [','], formatNoMatches: ''}" ng-list>
+  <input class="form-control" ng-model="$ctrl.value" ng-required="!$ctrl.field.nullable" crm-ui-select="{multiple: true, tags: [], tokenSeparators: [','], formatNoMatches: ''}" ng-list>
 </div>
index 464c92e43171c32a437044fac70a14fbc6712b64..b46e8014b31b1c27d01d11810d1ef874abbc06ca 100644 (file)
@@ -2,7 +2,7 @@
   <input disabled class="form-control loading" crm-ui-select="{data: []}">
 </div>
 <div class="form-group" ng-if="!$ctrl.multi && $ctrl.field.options !== true">
-  <input class="form-control" ng-model="$ctrl.value" crm-ui-select="{data: $ctrl.getFieldOptions}">
+  <input class="form-control" ng-model="$ctrl.value" crm-ui-select="{data: $ctrl.getFieldOptions, allowClear: $ctrl.field.nullable, placeholder: $ctrl.field.nullable ? ts('None') : ts('Select')}">
 </div>
 <div class="form-group" ng-if="$ctrl.multi && $ctrl.field.options !== true">
   <input class="form-control" ng-model="$ctrl.value" crm-ui-select="{data: $ctrl.getFieldOptions, multiple: true}" ng-list >
index 61a4391c48d534810e53c72d1260f5743cfae9bc..3718a11da75a6d77f6b0068f2b9a5af6b7453e90 100644 (file)
@@ -1,6 +1,6 @@
 <div class="form-group" ng-if="!$ctrl.multi" >
-  <input type="text" class="form-control" ng-model="$ctrl.value">
+  <input type="text" class="form-control" ng-model="$ctrl.value" ng-required="!$ctrl.field.nullable">
 </div>
 <div class="form-group" ng-if="$ctrl.multi" >
-  <input class="form-control" ng-model="$ctrl.value" crm-ui-select="{multiple: true, tags: [], tokenSeparators: [','], formatNoMatches: ''}" ng-list>
+  <input class="form-control" ng-model="$ctrl.value" ng-required="!$ctrl.field.nullable" crm-ui-select="{multiple: true, tags: [], tokenSeparators: [','], formatNoMatches: ''}" ng-list>
 </div>
index 5fd14a504c39384ce9ddb04c25830d8b764f07a0..01f41d16fd38fb606fd3c127dd931c89e757de6f 100644 (file)
@@ -13,7 +13,7 @@
 
     crmApi4(this.entity, 'getFields', {
       action: 'update',
-      select: ['name', 'label', 'description', 'input_type', 'data_type', 'serialize', 'options', 'fk_entity'],
+      select: ['name', 'label', 'description', 'input_type', 'data_type', 'serialize', 'options', 'fk_entity', 'nullable'],
       loadOptions: ['id', 'name', 'label', 'description', 'color', 'icon'],
       where: [["readonly", "=", false]],
     }).then(function(fields) {
index 9ae38110792efadda5f46d09c9a99311fff23cf4..42a9fcc2ee76ee5286b45b606e7c1f6a165e9bd2 100644 (file)
@@ -1,5 +1,5 @@
 <div id="bootstrap-theme">
-  <form ng-controller="crmSearchTaskUpdate as $ctrl">
+  <form name="crmSearchTaskUpdateForm" ng-controller="crmSearchTaskUpdate as $ctrl">
     <p><strong>{{:: ts('Update the %1 selected %2 with the following values:', {1: model.ids.length, 2: $ctrl.entityTitle}) }}</strong></p>
     <div class="form-inline" ng-repeat="clause in $ctrl.values" >
       <input class="form-control" ng-change="$ctrl.updateField($index)" ng-disabled="$ctrl.run" ng-model="clause[0]" crm-ui-select="{data: $ctrl.availableFields, allowClear: true, placeholder: 'Field'}" />
@@ -18,7 +18,7 @@
         <i class="crm-i fa-times"></i>
         {{:: ts('Cancel') }}
       </button>
-      <button ng-click="$ctrl.save()" class="btn btn-primary" ng-disabled="!$ctrl.values.length || $ctrl.run">
+      <button ng-click="$ctrl.save()" class="btn btn-primary" ng-disabled="!$ctrl.values.length || $ctrl.run || !crmSearchTaskUpdateForm.$valid">
         <i class="crm-i fa-{{ $ctrl.run ? 'spin fa-spinner' : 'check' }}"></i>
         {{:: ts('Update %1', {1: $ctrl.entityTitle}) }}
       </button>
index 04dd42a82044a99104120b36bad72a2ba3ee316e..d9953b202d3cd0ebd4f802f1eba24389bf6149c5 100644 (file)
@@ -65,6 +65,7 @@
         else if (action.uiDialog) {
           var options = CRM.utils.adjustDialogDefaults({
             autoOpen: false,
+            dialogClass: 'crm-search-task-dialog',
             title: action.title
           });
           dialogService.open('crmSearchTask', action.uiDialog.templateUrl, data, options)
index ca9225ef3c50707e1ab7df9829fd8c9620936ded..f69558afdc1701bd074a5054656878935faaba41 100644 (file)
@@ -33,9 +33,6 @@
   cursor: not-allowed;
 }
 
-#bootstrap-theme.crm-search input.ng-invalid {
-  border-color: #8a1f11;
-}
 #bootstrap-theme.crm-search input.ng-invalid::placeholder {
   color: #8a1f11;
 }
index 7326688ea2fecfb473f079da3f6493024fc1e73a..a3a736e74f97befcf37787ea437500aab27d588a 100644 (file)
   opacity: .5;
 }
 
+#bootstrap-theme.crm-search input.ng-invalid,
+#bootstrap-theme.crm-search-display input.ng-invalid,
+.crm-search-task-dialog #bootstrap-theme input.ng-invalid {
+  border-color: #8a1f11;
+}
+
 /* Loading placeholders */
 #bootstrap-theme .crm-search-loading-placeholder {
   height: 2em;
index 4cc32bbcf010bbbce27909a2dcd2647f4066d7d1..8c8c4c7df1047a664228625b1ce9c6101c8542d3 100644 (file)
@@ -130,12 +130,22 @@ class BasicCustomFieldTest extends BaseCustomValueTest {
         ->addValue('label', 'FavFood')
         ->addValue('custom_group_id', '$id')
         ->addValue('html_type', 'Text')
+        ->addValue('is_required', TRUE)
         ->addValue('data_type', 'String'))
       ->execute();
 
     // Test that no new option groups have been created (these are text fields with no options)
     $this->assertEquals($optionGroupCount, OptionGroup::get(FALSE)->selectRowCount()->execute()->count());
 
+    // Check getFields output
+    $fields = Contact::getFields(FALSE)->execute()->indexBy('name');
+    $this->assertFalse($fields['MyContactFields2.FavColor']['required']);
+    $this->assertTRUE($fields['MyContactFields2.FavColor']['nullable']);
+    // Custom fields are never actually *required* in the api, even if is_required = true
+    $this->assertFalse($fields['MyContactFields2.FavFood']['required']);
+    // But the api will report is_required as not nullable
+    $this->assertFalse($fields['MyContactFields2.FavFood']['nullable']);
+
     $contactId1 = Contact::create(FALSE)
       ->addValue('first_name', 'Johann')
       ->addValue('last_name', 'Tester')
index d5833dd9e788e15b79bdce44e60c7b40d4449c58..e58f0665203397e246d696d535778cbc68f68fa3 100644 (file)
@@ -20,6 +20,7 @@
 namespace api\v4\Action;
 
 use api\v4\UnitTestCase;
+use Civi\Api4\Activity;
 use Civi\Api4\Campaign;
 use Civi\Api4\Contact;
 use Civi\Api4\Contribution;
@@ -93,4 +94,15 @@ class GetFieldsTest extends UnitTestCase {
     $this->assertEquals(['name', 'label'], $fields['campaign_id']['suffixes']);
   }
 
+  public function testRequiredAndNullable() {
+    $actFields = Activity::getFields(FALSE)
+      ->setAction('create')
+      ->execute()->indexBy('name');
+
+    $this->assertTrue($actFields['activity_type_id']['required']);
+    $this->assertFalse($actFields['activity_type_id']['nullable']);
+    $this->assertFalse($actFields['subject']['required']);
+    $this->assertTrue($actFields['subject']['nullable']);
+  }
+
 }