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.
],
[
'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',
/**
* @var bool
*/
+ public $nullable = TRUE;
+
+ /**
+ * @var string
+ */
public $requiredIf;
/**
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
*/
}
/**
- * @return bool
+ * @return string
*/
public function getRequiredIf() {
return $this->requiredIf;
}
/**
- * @param bool $requiredIf
+ * @param string $requiredIf
*
* @return $this
*/
$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']);
$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'])) {
) {
continue;
}
- if ($action !== 'create' || isset($DAOField['default'])) {
- $DAOField['required'] = FALSE;
- }
if ($DAOField['name'] == 'is_active' && empty($DAOField['default'])) {
$DAOField['default'] = '1';
}
/**
* @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);
'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,
$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'],
]);
options: col.edit.options,
fk_entity: col.edit.fk_entity,
serialize: col.edit.serialize,
+ nullable: col.edit.nullable
};
$(document).on('keydown.crmSearchDisplayEditable', function(e) {
$scope.$apply(function() {
ctrl.cancel();
});
- } else if (e.key === 'Enter') {
- $scope.$apply(ctrl.save);
}
});
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;
});
-<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>
<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">
<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>
<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>
<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 >
<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>
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) {
<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'}" />
<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>
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)
cursor: not-allowed;
}
-#bootstrap-theme.crm-search input.ng-invalid {
- border-color: #8a1f11;
-}
#bootstrap-theme.crm-search input.ng-invalid::placeholder {
color: #8a1f11;
}
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;
->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')
namespace api\v4\Action;
use api\v4\UnitTestCase;
+use Civi\Api4\Activity;
use Civi\Api4\Campaign;
use Civi\Api4\Contact;
use Civi\Api4\Contribution;
$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']);
+ }
+
}