};
})
+ // Reformat an array of objects for compatibility with select2
+ .factory('formatForSelect2', function() {
+ return function(input, key, label, extra) {
+ return _.transform(input, function(result, item) {
+ var formatted = {id: item[key], text: item[label]};
+ if (extra) {
+ _.merge(formatted, _.pick(item, extra));
+ }
+ result.push(formatted);
+ }, []);
+ };
+ })
+
.run(function($rootScope, $location) {
/// Example: <button ng-click="goto('home')">Go home!</button>
$rootScope.goto = function(path) {
entity: '<'
},
require: {editor: '^^afGuiEditor'},
- controller: function ($scope, $timeout, afGui) {
+ controller: function ($scope, $timeout, afGui, formatForSelect2) {
var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin');
var ctrl = this;
$scope.controls = {};
return afGui.meta.entities[ctrl.getEntityType()];
};
- this.getBehaviors = function() {
- return CRM.afGuiEditor.behaviors[ctrl.getEntityType()];
- };
-
$scope.getField = afGui.getField;
$scope.valuesFields = function() {
$scope.controls.fieldSearch = '';
ctrl.buildPaletteLists();
});
+
+ ctrl.behaviors = _.transform(CRM.afGuiEditor.behaviors[ctrl.getEntityType()], function(behaviors, behavior) {
+ var item = _.cloneDeep(behavior);
+ item.options = formatForSelect2(item.modes, 'name', 'label', ['description', 'icon']);
+ behaviors.push(item);
+ }, []);
};
}
});
<div ng-include="'~/afGuiEditor/entityConfig/Options.html'"></div>
</fieldset>
-<fieldset ng-repeat="behavior in $ctrl.getBehaviors()">
- <label for="{{:: behavior.key + '_mode' }}">{{:: behavior.title }}</label>
- <select id="{{:: behavior.key + '_mode' }}" class="form-control" ng-model="$ctrl.entity[behavior.key]">
- <option value="">{{:: ts('None') }}</option>
- <option value="{{:: mode.name }}" ng-repeat="mode in behavior.modes">{{:: mode.label }}</option>
- </select>
- <p class="help-block" ng-show=":: behavior.description">{{:: behavior.description }}</p>
+<fieldset ng-repeat="behavior in $ctrl.behaviors">
+ <legend>{{:: behavior.title }}</legend>
+ <div ng-include="behavior.template || '~/afGuiEditor/behaviors/afGuiDefaultBehaviorTemplate.html'"></div>
</fieldset>
inputType = field.input_type,
dataType = field.data_type;
multi = field.serialize || dataType === 'Array';
+ $el.crmSelect2('destroy').crmDatepicker('destroy');
// Allow input_type to override dataType
if (inputType) {
multi = (dataType !== 'Boolean' &&
if (entity.data && entity.data[key] && entity.data[key] != value) {
filtersMatch = false;
}
- })
+ });
if (filtersMatch) {
options.push({id: entity.name, label: entity.label, icon: afGui.meta.entities[entity.type].icon});
}
});
- $el.crmAutocomplete(field.fk_entity, {fieldName: field.entity + '.' + field.name}, {
+ var params = field.entity && field.name ? {fieldName: field.entity + '.' + field.name} : {filters: filters};
+ $el.crmAutocomplete(field.fk_entity, params, {
multiple: multi,
"static": options,
minimumInputLength: options.length ? 1 : 0
ctrl.ngModel.$isEmpty = function(value) {
return !value || !value.length;
};
+ };
+ this.$onChanges = function() {
$timeout(function() {
makeWidget(ctrl.field);
});
--- /dev/null
+<!-- Default template for behaviors that do not provide their own template -->
+<input title="{{:: behavior.title }}" crm-ui-select="{data: behavior.options, placeholder: ts('None')}" class="form-control" ng-model="$ctrl.entity[behavior.key]">
+<p class="help-block" ng-show=":: behavior.description">{{:: behavior.description }}</p>
--- /dev/null
+<input title="{{:: behavior.title }}" crm-ui-select="{data: behavior.options, placeholder: ts('None')}" class="form-control" ng-model="$ctrl.entity[behavior.key]">
+
+<p class="help-block" ng-show="!$ctrl.entity.autofill">{{:: behavior.description }}</p>
+
+<div ng-if="$ctrl.entity.autofill && $ctrl.entity.autofill.indexOf('relationship:') === 0">
+ <autofill-relationship-behavior-form entity="$ctrl.entity" rel-types="behavior.modes" selected-type="$ctrl.entity.autofill">
+ </autofill-relationship-behavior-form>
+</div>
--- /dev/null
+// https://civicrm.org/licensing
+(function(angular, $, _) {
+ "use strict";
+
+ // For configuring autofill by related contact
+ angular.module('afGuiEditor').component('autofillRelationshipBehaviorForm', {
+ templateUrl: '~/afGuiEditor/behaviors/autofillRelationshipBehaviorForm.html',
+ bindings: {
+ entity: '<',
+ selectedType: '<',
+ relTypes: '<'
+ },
+ controller: function($scope, afGui) {
+ var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'),
+ ctrl = this;
+
+ this.getPlaceholder = function() {
+ var selectedType = _.find(ctrl.relTypes, {name: ctrl.selectedType}).contact_type || 'Contact';
+ return ts('Select %1', {1: afGui.getEntity(selectedType).label});
+ };
+
+ // Initialize or rebuild form field
+ this.$onChanges = function(changes) {
+ if (changes.selectedType) {
+ var selectedType = _.find(ctrl.relTypes, {name: ctrl.selectedType});
+ if (!ctrl.relatedContactField || ctrl.relatedContactField.input_attrs.filter.contact_type !== selectedType.contact_type) {
+ // Replacing the variable with a new object will trigger the afGuiFieldValue to refresh
+ ctrl.relatedContactField = {
+ input_type: 'EntityRef',
+ data_type: 'Integer',
+ fk_entity: 'Contact',
+ input_attrs: {filter: {}}
+ };
+ if (selectedType.contact_type) {
+ ctrl.relatedContactField.input_attrs.filter.contact_type = selectedType.contact_type;
+ }
+ }
+ }
+ };
+ }
+ });
+
+})(angular, CRM.$, CRM._);
--- /dev/null
+<input
+ class="form-control"
+ required
+ placeholder="{{ $ctrl.getPlaceholder() }}"
+ ng-model="$ctrl.entity['autofill-relationship']"
+ af-gui-field-value="$ctrl.relatedContactField"
+ >
return NULL;
}
+ /**
+ * Optional template for configuring the behavior in the AfformGuiEditor
+ *
+ * @return string|null
+ */
+ public static function getTemplate(): ?string {
+ return NULL;
+ }
+
/**
* Dashed name, name of entity attribute for selected mode
* @return string
namespace Civi\Afform\Behavior;
use Civi\Afform\AbstractBehavior;
+use Civi\Afform\Event\AfformEntitySortEvent;
use Civi\Afform\Event\AfformPrefillEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use CRM_Afform_ExtensionUtil as E;
*/
public static function getSubscribedEvents() {
return [
+ 'civi.afform.sort.prefill' => 'onAfformSortPrefill',
'civi.afform.prefill' => ['onAfformPrefill', 99],
];
}
public static function getEntities():array {
- return ['Individual'];
+ return \CRM_Contact_BAO_ContactType::basicTypes();
}
public static function getTitle():string {
}
public static function getDescription():string {
- return E::ts('Automatically identify this contact');
+ return E::ts('Automatically identify this contact based on logged-in status or relationship to another contact on the form.');
}
- public static function getModes(string $entityName):array {
+ public static function getTemplate(): ?string {
+ return '~/afGuiEditor/behaviors/autofillRelationshipBehavior.html';
+ }
+
+ public static function getModes(string $contactType):array {
$modes = [];
- $modes[] = [
- 'name' => 'user',
- 'label' => E::ts('Current User'),
- ];
+ if ($contactType === 'Individual') {
+ $modes[] = [
+ 'name' => 'user',
+ 'label' => E::ts('Current User'),
+ 'description' => E::ts('Auto-select logged-in user'),
+ 'icon' => 'fa-user-circle',
+ ];
+ }
+ $relationshipTypes = \Civi\Api4\RelationshipType::get(FALSE)
+ ->addSelect('name_a_b', 'name_b_a', 'label_a_b', 'label_b_a', 'description', 'contact_type_a', 'contact_type_b')
+ ->addWhere('is_active', '=', TRUE)
+ ->addClause('OR', ['contact_type_a', '=', $contactType], ['contact_type_a', 'IS NULL'], ['contact_type_b', '=', $contactType], ['contact_type_b', 'IS NULL'])
+ ->execute();
+ foreach ($relationshipTypes as $relationshipType) {
+ if (!$relationshipType['contact_type_a'] || $relationshipType['contact_type_a'] === $contactType) {
+ $modes[] = [
+ 'name' => 'relationship:' . $relationshipType['name_a_b'],
+ 'label' => $relationshipType['label_a_b'],
+ 'description' => $relationshipType['description'],
+ 'icon' => 'fa-handshake-o',
+ 'contact_type' => $relationshipType['contact_type_b'],
+ ];
+ }
+ if (
+ $relationshipType['name_b_a'] && $relationshipType['name_a_b'] != $relationshipType['name_b_a'] &&
+ (!$relationshipType['contact_type_b'] || $relationshipType['contact_type_b'] === $contactType)
+ ) {
+ $modes[] = [
+ 'name' => 'relationship:' . $relationshipType['name_b_a'],
+ 'label' => $relationshipType['label_b_a'],
+ 'description' => $relationshipType['description'],
+ 'icon' => 'fa-handshake-o',
+ 'contact_type' => $relationshipType['contact_type_a'],
+ ];
+ }
+ }
return $modes;
}
- public static function onAfformPrefill(AfformPrefillEvent $event) {
+ public static function onAfformSortPrefill(AfformEntitySortEvent $event): void {
+ foreach ($event->getFormDataModel()->getEntities() as $entityName => $entity) {
+ $autoFillMode = $entity['autofill'] ?? '';
+ $relatedContact = $entity['autofill-relationship'] ?? NULL;
+ if ($relatedContact && strpos($autoFillMode, 'relationship:') === 0) {
+ $event->addDependency($entityName, $relatedContact);
+ }
+ }
+ }
+
+ public static function onAfformPrefill(AfformPrefillEvent $event): void {
if ($event->getEntityType() === 'Contact') {
$entity = $event->getEntity();
$id = $event->getEntityId();
+ $autoFillMode = $entity['autofill'] ?? '';
+ $relatedContact = $entity['autofill-relationship'] ?? NULL;
// Autofill with current user
- if (!$id && ($entity['autofill'] ?? NULL) === 'user') {
+ if (!$id && $autoFillMode === 'user') {
$id = \CRM_Core_Session::getLoggedInContactID();
if ($id) {
$event->getApiRequest()->loadEntity($entity, [$id]);
}
}
+ // Autofill by relationship
+ if (!$id && $relatedContact && strpos($autoFillMode, 'relationship:') === 0) {
+ $relationshipType = substr($autoFillMode, strlen('relationship:'));
+ $relatedEntity = $event->getFormDataModel()->getEntity($relatedContact);
+ if ($relatedEntity) {
+ $relatedContact = $event->getEntityIds($relatedContact)[0] ?? NULL;
+ }
+ if ($relatedContact) {
+ $relations = \Civi\Api4\RelationshipCache::get(FALSE)
+ ->addSelect('near_contact_id')
+ ->addWhere('near_relation', '=', $relationshipType)
+ ->addWhere('far_contact_id', '=', $relatedContact)
+ ->addWhere('near_contact_id.is_deleted', '=', FALSE)
+ ->addWhere('is_current', '=', TRUE)
+ ->execute()->column('near_contact_id');
+ $event->getApiRequest()->loadEntity($entity, $relations);
+ }
+ }
}
}
*/
public static function getDescription():? string;
+ /**
+ * Optional template for configuring the behavior in the AfformGuiEditor
+ *
+ * @return string|null
+ */
+ public static function getTemplate(): ?string;
+
/**
* Dashed name, name of entity attribute for selected mode
* @return string
--- /dev/null
+<?php
+
+namespace Civi\Afform\Event;
+
+use MJS\TopSort\Implementations\FixedArraySort;
+
+/**
+ * This event allows listeners to declare that entities depend on others.
+ * These dependencies change the order in which entities are resolved.
+ */
+class AfformEntitySortEvent extends AfformBaseEvent {
+
+ private $dependencies = [];
+
+ /**
+ * @param string $dependentEntity
+ * @param string $dependsOnEntity
+ */
+ public function addDependency(string $dependentEntity, string $dependsOnEntity): void {
+ $this->dependencies[$dependentEntity][$dependsOnEntity] = $dependsOnEntity;
+ }
+
+ /**
+ * Returns entity names sorted by their dependencies
+ *
+ * @return array
+ */
+ public function getSortedEnties(): array {
+ $sorter = new FixedArraySort();
+ $formEntities = array_keys($this->getFormDataModel()->getEntities());
+ foreach ($formEntities as $entityName) {
+ // Add all dependencies that are the valid name of another entitiy
+ $dependencies = array_intersect($this->dependencies[$entityName] ?? [], $formEntities);
+ $sorter->add($entityName, $dependencies);
+ }
+ return $sorter->sort();
+ }
+
+}
}
/**
- * Get the id of a saved record
+ * Get the id of an instance of the current entity
* @param int $index
* @return mixed
*/
public function getEntityId(int $index = 0) {
- $idField = CoreUtil::getIdFieldName($this->entityName);
+ $apiEntity = $this->getFormDataModel()->getEntity($this->entityName)['type'];
+ $idField = CoreUtil::getIdFieldName($apiEntity);
return $this->entityIds[$this->entityName][$index][$idField] ?? NULL;
}
+ /**
+ * Get the id(s) of an entity
+ *
+ * @param string|null $entityName
+ * @return array
+ */
+ public function getEntityIds(string $entityName = NULL): array {
+ $entityName = $entityName ?: $this->entityName;
+ $apiEntity = $this->getFormDataModel()->getEntity($this->entityName)['type'];
+ $idField = CoreUtil::getIdFieldName($apiEntity);
+ return array_column($this->entityIds[$entityName] ?? [], $idField);
+ }
+
/**
* @param int $index
* @param string $joinEntity
namespace Civi\Api4\Action\Afform;
+use Civi\Afform\Event\AfformEntitySortEvent;
use Civi\Afform\Event\AfformPrefillEvent;
use Civi\Afform\FormDataModel;
use Civi\Api4\Generic\Result;
* Load all entities
*/
protected function loadEntities() {
- foreach ($this->_formDataModel->getEntities() as $entityName => $entity) {
+ $sorter = new AfformEntitySortEvent($this->_afform, $this->_formDataModel, $this);
+ \Civi::dispatcher()->dispatch('civi.afform.sort.prefill', $sorter);
+ $sortedEntities = $sorter->getSortedEnties();
+ foreach ($sortedEntities as $entityName) {
+ $entity = $this->_formDataModel->getEntity($entityName);
$this->_entityIds[$entityName] = [];
$idField = CoreUtil::getIdFieldName($entity['type']);
if (!empty($entity['actions']['update'])) {
(!empty($entity['url-autofill']) || isset($entity['fields'][$idField]))
) {
$ids = (array) $this->args[$entityName];
- // Limit number of records to 1 unless using af-repeat
- $ids = array_slice($ids, 0, !empty($entity['af-repeat']) ? $entity['max'] ?? NULL : 1);
$this->loadEntity($entity, $ids);
}
}
* @param array $ids
*/
public function loadEntity(array $entity, array $ids) {
+ // Limit number of records based on af-repeat settings
+ // If 'min' is set then it is repeatable, and max will either be a number or NULL for unlimited.
+ $ids = array_slice($ids, 0, isset($entity['min']) ? $entity['max'] : 1);
+
$api4 = $this->_formDataModel->getSecureApi4($entity['name']);
$idField = CoreUtil::getIdFieldName($entity['type']);
- if (!empty($entity['fields'][$idField]['saved_search'])) {
+ if ($ids && !empty($entity['fields'][$idField]['saved_search'])) {
$ids = $this->validateBySavedSearch($entity, $ids);
}
if (!$ids) {
'title' => $behaviorClass::getTitle(),
'description' => $behaviorClass::getDescription(),
'entities' => $entities,
+ 'template' => $behaviorClass::getTemplate(),
// Get modes for every supported entity
'modes' => array_map([$behaviorClass, 'getModes'], array_combine($entities, $entities)),
];
'data_type' => 'String',
'description' => 'Optional localized description displayed on admin screen',
],
+ [
+ 'name' => 'template',
+ 'data_type' => 'String',
+ 'description' => 'Optional template for configuring the behavior in the AfformGuiEditor',
+ ],
[
'name' => 'entities',
'data_type' => 'Array',
$this->assertEquals('Firsty3', $saved[1]['contact_id_a.first_name']);
}
+ public function testPrefillContactsByRelationship(): void {
+ $layout = <<<EOHTML
+<af-form ctrl="afform">
+ <af-entity data="{contact_type: 'Individual'}" type="Contact" name="Individual1" label="Individual 1" actions="{create: true, update: true}" security="RBAC" autofill="relationship:Child of" autofill-relationship="Individual2"/>
+ <af-entity data="{contact_type: 'Organization'}" type="Contact" name="Organization1" label="Organization 1" actions="{create: true, update: true}" security="RBAC" url-autofill="1" />
+ <af-entity data="{contact_type: 'Individual'}" type="Contact" name="Individual2" label="Individual 2" actions="{create: true, update: true}" security="RBAC" autofill="relationship:Employee of" autofill-relationship="Organization1"/>
+ <fieldset af-fieldset="Individual1" class="af-container" af-title="Individual 1" af-repeat="Add" min="1" max="2">
+ <afblock-name-individual></afblock-name-individual>
+ </fieldset>
+ <fieldset af-fieldset="Individual2" class="af-container" af-title="Individual 2">
+ <afblock-name-individual></afblock-name-individual>
+ </fieldset>
+ <fieldset af-fieldset="Organization1" class="af-container" af-title="Organization1">
+ <afblock-name-organization></afblock-name-organization>
+ </fieldset>
+</af-form>
+EOHTML;
+
+ $this->useValues([
+ 'layout' => $layout,
+ 'permission' => CRM_Core_Permission::ALWAYS_ALLOW_PERMISSION,
+ ]);
+
+ $contact = \Civi\Api4\Contact::save(FALSE)
+ ->addRecord(['first_name' => 'Child1'])
+ ->addRecord(['first_name' => 'Child2', 'is_deleted' => TRUE])
+ ->addRecord(['first_name' => 'Parent'])
+ ->addRecord(['organization_name' => 'Employer'])
+ ->addRecord(['first_name' => 'Child3'])
+ ->addRecord(['first_name' => 'Child4'])
+ ->addRecord(['first_name' => 'Child5'])
+ ->execute()->column('id');
+
+ Relationship::save(FALSE)
+ ->addRecord(['contact_id_a' => $contact[2], 'contact_id_b' => $contact[3], 'relationship_type_id:name' => 'Employee of'])
+ ->addRecord(['contact_id_a' => $contact[0], 'contact_id_b' => $contact[2], 'relationship_type_id:name' => 'Child of'])
+ ->addRecord(['contact_id_a' => $contact[1], 'contact_id_b' => $contact[2], 'relationship_type_id:name' => 'Child of'])
+ ->addRecord(['contact_id_a' => $contact[4], 'contact_id_b' => $contact[2], 'relationship_type_id:name' => 'Child of', 'is_active' => FALSE])
+ ->addRecord(['contact_id_a' => $contact[5], 'contact_id_b' => $contact[2], 'relationship_type_id:name' => 'Child of'])
+ ->addRecord(['contact_id_a' => $contact[6], 'contact_id_b' => $contact[2], 'relationship_type_id:name' => 'Child of'])
+ ->execute();
+
+ $prefill = Civi\Api4\Afform::prefill(FALSE)
+ ->setName($this->formName)
+ ->setArgs(['Organization1' => $contact[3]])
+ ->execute()
+ ->indexBy('name');
+
+ $this->assertEquals('Employer', $prefill['Organization1']['values'][0]['fields']['organization_name']);
+ $this->assertEquals('Parent', $prefill['Individual2']['values'][0]['fields']['first_name']);
+ $this->assertEquals('Child1', $prefill['Individual1']['values'][0]['fields']['first_name']);
+ $this->assertEquals('Child4', $prefill['Individual1']['values'][1]['fields']['first_name']);
+ // No room on form for a 3rd child because af-repeat max=2
+ $this->assertCount(2, $prefill['Individual1']['values']);
+ }
+
}
$result = [];
$selectAliases = array_map(function($select) {
return array_slice(explode(' AS ', $select), -1)[0];
- }, $this->savedSearch['api_params']['select']);
+ }, $this->_apiParams['select']);
foreach ($selectAliases as $alias) {
[$alias] = explode(':', $alias);
$result[] = $alias;
* @param array $apiParams
*/
protected function augmentSelectClause(&$apiParams): void {
- $existing = array_map(function($item) {
- return explode(' AS ', $item)[1] ?? $item;
- }, $apiParams['select']);
// Add primary key field if actions are enabled
// (only needed for non-dao entities, as Api4SelectQuery will auto-add the id)
if (!in_array('DAOEntity', CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'type')) &&
// Pager can operate in "page" mode for traditional pager, or "scroll" mode for infinite scrolling
$pagerMode = NULL;
+ $this->augmentSelectClause($apiParams);
+ $this->applyFilters();
+
switch ($this->return) {
case 'id':
$key = CoreUtil::getIdFieldName($entityName);
break;
case 'tally':
- $this->applyFilters();
unset($apiParams['orderBy'], $apiParams['limit']);
$api = Request::create($entityName, 'get', $apiParams);
$query = new Api4SelectQuery($api);
$apiParams['limit']++;
}
$apiParams['orderBy'] = $this->getOrderByFromSort();
- $this->augmentSelectClause($apiParams);
}
- $this->applyFilters();
-
$apiResult = civicrm_api4($entityName, 'get', $apiParams, $index);
// Copy over meta properties to this result
$result->rowCount = $apiResult->rowCount;
"use strict";
// Declare module
- angular.module('crmSearchTasks', CRM.angRequires('crmSearchTasks'))
-
- // Reformat an array of objects for compatibility with select2
- // Todo this probably belongs in core
- .factory('formatForSelect2', function() {
- return function(input, key, label, extra) {
- return _.transform(input, function(result, item) {
- var formatted = {id: item[key], text: item[label]};
- if (extra) {
- _.merge(formatted, _.pick(item, extra));
- }
- result.push(formatted);
- }, []);
- };
- });
+ angular.module('crmSearchTasks', CRM.angRequires('crmSearchTasks'));
})(angular, CRM.$, CRM._);