Commit 8e070ebf authored by colemanw's avatar colemanw
Browse files

Implement afform blocks for Email, Phone, IM, Website

Blocks are reusable, repeatable afforms that extend other entities
parent 719ac93d
......@@ -379,7 +379,7 @@ class CRM_Afform_ArrayHtml {
* @return bool
*/
public function isNodeEditable(array $item) {
if ($item['#tag'] === 'af-field' || $item['#tag'] === 'af-form' || isset($item['af-fieldset'])) {
if ($item['#tag'] === 'af-field' || $item['#tag'] === 'af-form' || isset($item['af-fieldset']) || isset($item['af-block'])) {
return TRUE;
}
$editableClasses = ['af-container', 'af-text', 'af-button'];
......
......@@ -33,7 +33,7 @@ class AHQ {
* @return array
*/
public static function getTags($element, $tagName) {
if ($element === [] || is_string($element) || isset($element['#text'])) {
if (!is_array($element) || !isset($element['#tag'])) {
return [];
}
$results = [];
......
......@@ -18,7 +18,7 @@ class FormDataModel {
protected $entities;
/**
* Gets entity metadata and all fields from the form
* Gets entity metadata and all blocks & fields from the form
*
* @param array $layout
* The root element of the layout, in shallow/deep format.
......@@ -29,9 +29,9 @@ class FormDataModel {
$root = AHQ::makeRoot($layout);
$entities = array_column(AHQ::getTags($root, 'af-entity'), NULL, 'name');
foreach (array_keys($entities) as $entity) {
$entities[$entity]['fields'] = [];
$entities[$entity]['fields'] = $entities[$entity]['blocks'] = [];
}
self::parseFields($root, $entities);
self::parseFields($layout, $entities);
$self = new static();
$self->entities = $entities;
......@@ -39,26 +39,29 @@ class FormDataModel {
}
/**
* @param array $element
* The root element of the layout, in shallow/deep format.
* @param array $nodes
* @param array $entities
* A list of entities, keyed by named.
* This will be updated to include 'fields'.
* Ex: $entities['spouse']['type'] = 'Contact';
* A list of entities, keyed by name.
* This will be updated to populate 'fields' and 'blocks'.
* Ex: $entities['spouse']['type'] = 'Contact';
* @param string $entity
*/
protected static function parseFields($element, &$entities) {
if (!isset($element['#children'])) {
return;
}
foreach ($element['#children'] as $child) {
if (is_string($child) || isset($child['#text'])) {
protected static function parseFields($nodes, &$entities, $entity = NULL) {
foreach ($nodes as $node) {
if (!is_array($node) || !isset($node['#tag'])) {
//nothing
}
elseif (!empty($child['af-fieldset']) && !empty($child['#children'])) {
$entities[$child['af-fieldset']]['fields'] = array_merge($entities[$child['af-fieldset']]['fields'] ?? [], AHQ::getTags($child, 'af-field'));
elseif (!empty($node['af-fieldset'])) {
self::parseFields($node['#children'], $entities, $node['af-fieldset']);
}
elseif ($entity && $node['#tag'] === 'af-field') {
$entities[$entity]['fields'][$node['name']] = AHQ::getProps($node);
}
elseif ($entity && !empty($node['af-block'])) {
$entities[$entity]['blocks'][$node['af-block']] = AHQ::getProps($node);
}
else {
self::parseFields($child, $entities);
elseif (!empty($node['#children'])) {
self::parseFields($node['#children'], $entities, $entity);
}
}
}
......
......@@ -32,6 +32,10 @@ abstract class AbstractProcessor extends \Civi\Api4\Generic\AbstractAction {
*/
protected $_formDataModel;
/**
* @param \Civi\Api4\Generic\Result $result
* @throws \API_Exception
*/
public function _run(Result $result) {
// This will throw an exception if the form doesn't exist
$this->_afform = (array) civicrm_api4('Afform', 'get', ['checkPermissions' => FALSE, 'where' => [['name', '=', $this->name]]], 0);
......
......@@ -29,7 +29,7 @@ class Prefill extends AbstractProcessor {
}
/**
* Fetch all fields needed to display a given entity on this form
* Fetch all data needed to display a given entity on this form
*
* @param $entity
* @param $id
......@@ -46,11 +46,20 @@ class Prefill extends AbstractProcessor {
}
$result = civicrm_api4($entity['type'], 'get', [
'where' => [['id', '=', $id]],
'select' => array_column($entity['fields'], 'name'),
'select' => array_keys($entity['fields']),
'checkPermissions' => $checkPermissions,
]);
if ($result->first()) {
$this->_data[$entity['name']] = $result->first();
$data = $result->first();
if ($data) {
$data['blocks'] = [];
foreach ($entity['blocks'] ?? [] as $blockEntity => $block) {
$data['blocks'][$blockEntity] = (array) civicrm_api4($blockEntity, 'get', [
'where' => [['contact_id', '=', $data['id']]],
'limit' => $block['max'] ?? 0,
'checkPermissions' => $checkPermissions,
]);
}
$this->_data[$entity['name']] = $data;
}
}
......
......@@ -3,6 +3,7 @@
namespace Civi\Api4\Action\Afform;
use Civi\Afform\Event\AfformSubmitEvent;
use Civi\API\Exception\NotImplementedException;
/**
* Class Submit
......@@ -38,22 +39,42 @@ class Submit extends AbstractProcessor {
return [];
}
///**
// * @param \Civi\Afform\Event\AfformSubmitEvent $event
// * @see afform_civicrm_config
// */
//public function processContacts(AfformSubmitEvent $event) {
// if (empty($event->entityValues['Contact'])) {
// return;
// }
// foreach ($event->entityValues['Contact'] as $entityName => $contact) {
// // Do something
// unset($event->entityValues['Contact'][$entityName]);
// }
//}
/**
* @param \Civi\Afform\Event\AfformSubmitEvent $event
* @throws \API_Exception
* @see afform_civicrm_config
*/
public function processContacts(AfformSubmitEvent $event) {
foreach ($event->entityValues['Contact'] ?? [] as $entityName => $contact) {
$blocks = $contact['blocks'] ?? [];
unset($contact['blocks']);
$saved = civicrm_api4('Contact', 'save', ['records' => [$contact]])->first();
foreach ($blocks as $entity => $block) {
$values = self::filterEmptyBlocks($entity, $block);
// FIXME: Replace/delete should only be done to known contacts
if ($values) {
civicrm_api4($entity, 'replace', [
'where' => [['contact_id', '=', $saved['id']]],
'records' => $values,
]);
}
else {
try {
civicrm_api4($entity, 'delete', [
'where' => [['contact_id', '=', $saved['id']]],
]);
} catch (\API_Exception $e) {
// No records to delete
}
}
}
}
unset($event->entityValues['Contact']);
}
/**
* @param \Civi\Afform\Event\AfformSubmitEvent $event
* @throws \API_Exception
* @see afform_civicrm_config
*/
public static function processGenericEntity(AfformSubmitEvent $event) {
......@@ -65,4 +86,33 @@ class Submit extends AbstractProcessor {
}
}
/**
* Filter out blocks that have been left blank on the form
*
* @param $entity
* @param $block
* @return array
*/
private static function filterEmptyBlocks($entity, $block) {
return array_filter($block, function($item) use($entity) {
switch ($entity) {
case 'Email':
return !empty($item['email']);
case 'Phone':
return !empty($item['phone']);
case 'IM':
return !empty($item['name']);
case 'Website':
return !empty($item['url']);
default:
\CRM_Utils_Array::remove($item, 'id', 'is_primary', 'location_type_id', 'entity_id', 'contact_id', 'entity_table');
return (bool) array_filter($item);
}
});
}
}
......@@ -93,6 +93,12 @@ class Afform extends AbstractEntity {
[
'name' => 'requires',
],
[
'name' => 'block',
],
[
'name' => 'extends',
],
[
'name' => 'title',
'required' => $self->getAction() === 'create',
......@@ -104,6 +110,10 @@ class Afform extends AbstractEntity {
'name' => 'is_public',
'data_type' => 'Boolean',
],
[
'name' => 'repeatable',
'data_type' => 'Boolean',
],
[
'name' => 'server_route',
],
......
......@@ -7,7 +7,7 @@ namespace Civi\Api4\Utils;
*
* @method $this setLayoutFormat(string $layoutFormat)
* @method string getLayoutFormat()
* @method $this setFormatWhitespace(string $layoutFormat)
* @method $this setFormatWhitespace(string $formatWhitespace)
* @method string getFormatWhitespace()
*/
trait AfformFormatTrait {
......
......@@ -54,7 +54,7 @@ function afform_civicrm_config(&$config) {
}
Civi::$statics[__FUNCTION__] = 1;
// Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processContacts'], -500);
Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processContacts'], 500);
Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processGenericEntity'], -1000);
Civi::dispatcher()->addListener('hook_civicrm_angularModules', '_afform_civicrm_angularModules_autoReq', -1000);
}
......@@ -150,12 +150,7 @@ function afform_civicrm_caseTypes(&$caseTypes) {
/**
* Implements hook_civicrm_angularModules().
*
* Generate a list of Angular modules.
*
* Note: This hook only runs in CiviCRM 4.5+. It may
* use features only available in v4.6+.
*
* @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules
* Generate a list of Afform Angular modules.
*/
function afform_civicrm_angularModules(&$angularModules) {
_afform_civix_civicrm_angularModules($angularModules);
......@@ -325,56 +320,74 @@ function _afform_reverse_deps_find($formName, $html, $revMap) {
function afform_civicrm_alterAngular($angular) {
$fieldMetadata = \Civi\Angular\ChangeSet::create('fieldMetadata')
->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
$entities = _afform_getMetadata($doc);
/** @var \CRM_Afform_AfformScanner $scanner */
$scanner = Civi::service('afform_scanner');
$meta = $scanner->getMeta(basename($path, '.aff.html'));
$blockEntity = $meta['block'] ?? NULL;
if (!$blockEntity) {
$entities = _afform_getMetadata($doc);
}
foreach (pq('af-field', $doc) as $afField) {
/** @var DOMElement $afField */
$fieldName = $afField->getAttribute('name');
$entityName = pq($afField)->parents('[af-fieldset]')->attr('af-fieldset');
if (!preg_match(';^[a-zA-Z0-9\_\-\. ]+$;', $entityName)) {
$blockName = pq($afField)->parents('[af-block]')->attr('af-block');
if (!$blockEntity && !preg_match(';^[a-zA-Z0-9\_\-\. ]+$;', $entityName)) {
throw new \CRM_Core_Exception("Cannot process $path: malformed entity name ($entityName)");
}
$entityType = $entities[$entityName]['type'];
$getFields = civicrm_api4($entityType, 'getFields', [
'action' => 'create',
'where' => [['name', '=', $fieldName]],
'select' => ['title', 'input_type', 'input_attrs', 'options'],
'loadOptions' => TRUE,
]);
// Merge field definition data with whatever's already in the markup
$deep = ['input_attrs'];
foreach ($getFields as $fieldInfo) {
$existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
// If it's not an object, don't mess with it.
continue;
}
// TODO: Teach the api to return options in this format
if (!empty($fieldInfo['options'])) {
$fieldInfo['options'] = CRM_Utils_Array::makeNonAssociative($fieldInfo['options'], 'key', 'label');
}
// Default placeholder for select inputs
if ($fieldInfo['input_type'] === 'Select') {
$fieldInfo['input_attrs'] = ($fieldInfo['input_attrs'] ?? []) + ['placeholder' => ts('Select')];
}
$fieldDefn = $existingFieldDefn ? CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
foreach ($fieldInfo as $name => $prop) {
// Merge array props 1 level deep
if (in_array($name, $deep) && !empty($fieldDefn[$name])) {
$fieldDefn[$name] = CRM_Utils_JS::writeObject(CRM_Utils_JS::getRawProps($fieldDefn[$name]) + array_map(['CRM_Utils_JS', 'encode'], $prop));
}
elseif (!isset($fieldDefn[$name])) {
$fieldDefn[$name] = CRM_Utils_JS::encode($prop);
}
}
pq($afField)->attr('defn', htmlspecialchars(CRM_Utils_JS::writeObject($fieldDefn)));
}
$entityType = $blockEntity ?? $entities[$entityName]['type'];
_af_fill_field_metadata($blockName ? $blockName : $entityType, $afField);
}
});
$angular->add($fieldMetadata);
}
/**
* Merge field definition metadata into an afform field's definition
*
* @param $entityType
* @param DOMElement $afField
* @throws API_Exception
*/
function _af_fill_field_metadata($entityType, DOMElement $afField) {
$fieldName = $afField->getAttribute('name');
$getFields = civicrm_api4($entityType, 'getFields', [
'action' => 'create',
'where' => [['name', '=', $fieldName]],
'select' => ['title', 'input_type', 'input_attrs', 'options'],
'loadOptions' => TRUE,
]);
// Merge field definition data with whatever's already in the markup
$deep = ['input_attrs'];
foreach ($getFields as $fieldInfo) {
$existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
// If it's not an object, don't mess with it.
continue;
}
// TODO: Teach the api to return options in this format
if (!empty($fieldInfo['options'])) {
$fieldInfo['options'] = CRM_Utils_Array::makeNonAssociative($fieldInfo['options'], 'key', 'label');
}
// Default placeholder for select inputs
if ($fieldInfo['input_type'] === 'Select') {
$fieldInfo['input_attrs'] = ($fieldInfo['input_attrs'] ?? []) + ['placeholder' => ts('Select')];
}
$fieldDefn = $existingFieldDefn ? CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
foreach ($fieldInfo as $name => $prop) {
// Merge array props 1 level deep
if (in_array($name, $deep) && !empty($fieldDefn[$name])) {
$fieldDefn[$name] = CRM_Utils_JS::writeObject(CRM_Utils_JS::getRawProps($fieldDefn[$name]) + array_map(['CRM_Utils_JS', 'encode'], $prop));
}
elseif (!isset($fieldDefn[$name])) {
$fieldDefn[$name] = CRM_Utils_JS::encode($prop);
}
}
pq($afField)->attr('defn', htmlspecialchars(CRM_Utils_JS::writeObject($fieldDefn)));
}
}
function _afform_getMetadata(phpQueryObject $doc) {
$entities = [];
foreach ($doc->find('af-entity') as $afmModelProp) {
......@@ -412,8 +425,6 @@ function afform_civicrm_themes(&$themes) {
_afform_civix_civicrm_themes($themes);
}
// --- Functions below this ship commented out. Uncomment as required. ---
/**
* Implements hook_civicrm_buildAsset().
*/
......
......@@ -18,6 +18,8 @@ return [
'af-entity' => 'E',
'af-fieldset' => 'A',
'af-form' => 'E',
'af-block' => 'A',
'af-block-item' => 'A',
'af-field' => 'E',
],
];
(function(angular, $, _) {
// Example usage: <div af-block="Email" min="1" max="3" add-label="Add email" ><div block-email-default /></div>
angular.module('af')
.directive('afBlock', function() {
return {
restrict: 'A',
require: ['^^afFieldset'],
scope: {
blockName: '@afBlock',
min: '=',
max: '=',
addLabel: '@',
addIcon: '@'
},
transclude: true,
templateUrl: '~/af/afBlock.html',
link: function($scope, $el, $attr, ctrls) {
var ts = $scope.ts = CRM.ts('afform');
$scope.afFieldset = ctrls[0];
},
controller: function($scope) {
this.getItems = $scope.getItems = function() {
var data = $scope.afFieldset.getData();
data.blocks = data.blocks || {};
var block = (data.blocks[$scope.blockName] = data.blocks[$scope.blockName] || []);
while ($scope.min && block.length < $scope.min) {
block.push({});
}
return block;
};
$scope.addItem = function() {
$scope.getItems().push({});
};
$scope.removeItem = function(index) {
$scope.getItems().splice(index, 1);
};
$scope.canAdd = function() {
return !$scope.max || $scope.getItems().length < $scope.max;
};
$scope.canRemove = function() {
return !$scope.min || $scope.getItems().length > $scope.min;
};
}
};
})
.directive('afBlockItem', function() {
return {
restrict: 'A',
scope: {
item: '=afBlockItem'
},
controller: function($scope) {
this.getData = function() {
return $scope.item;
};
}
};
});
})(angular, CRM.$, CRM._);
......@@ -3,22 +3,20 @@
angular.module('af').directive('afField', function() {
return {
restrict: 'E',
require: ['^afFieldset', '^afForm'],
require: ['^^afFieldset', '?^afBlockItem'],
templateUrl: '~/af/afField.html',
scope: {
fieldName: '@name',
defn: '='
},
link: function($scope, $el, $attr, ctrls) {
var ts = $scope.ts = CRM.ts('afform');
$scope.afFieldset = ctrls[0];
var modelList = ctrls[1];
$scope.fieldId = $scope.afFieldset.getDefn().modelName + '-' + $scope.fieldName;
$scope.getData = $scope.afFieldset.getData;
var ts = $scope.ts = CRM.ts('afform'),
afFieldset = ctrls[0],
blockItem = ctrls[1];
$scope.fieldId = afFieldset.getDefn().modelName + '-' + $scope.fieldName;
$scope.getData = blockItem ? blockItem.getData : afFieldset.getData;
$el.addClass('af-field-type-' + _.kebabCase($scope.defn.input_type));
},
controller: function($scope) {
$scope.getOptions = function() {
return $scope.defn.options || [{key: '1', label: ts('Yes')}, {key: '0', label: ts('No')}];
......
......@@ -47,7 +47,8 @@
crmApi4('Afform', 'prefill', {name: this.getFormMeta().name, args: $routeParams})
.then(function(result) {
_.each(result, function(item) {
data[item.name] = _.extend(item.values, schema[item.name].data || {});
data[item.name] = data[item.name] || {};
_.extend(data[item.name], item.values, schema[item.name].data || {});
});
});
}
......
<div af-block-item="item" ng-repeat="item in getItems()">
<ng-transclude />
<button crm-icon="fa-ban" class="btn btn-xs af-block-remove-btn" ng-if="canRemove()" ng-click="removeItem($index)"></button>
</div>
<button crm-icon="{{ addIcon || 'fa-plus' }}" class="btn btn-sm af-block-add-btn" ng-if="canAdd()" ng-click="addItem()">{{ addLabel }}</button>
<?php
// This file declares an Angular module which can be autoloaded
// in CiviCRM. See also:
// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules
return [
'js' => [
'ang/afBlock.js',
'ang/afBlock/*.js',
'ang/afBlock/*/*.js',
],
'css' => ['ang/afBlock.css'],
'partials' => ['ang/afBlock'],
'requires' => [
'crmUi',
'crmUtil',
],
'settings' => [],
'basePages' => [],
'exports' => [
'af-block-contact-name' => 'AE',
'af-block-contact-email' => 'AE',
],
];
/* Add any CSS rules for Angular module "afBlock" */
(function(angular, $, _) {
// Declare a list of dependencies.
angular.module('afBlock', CRM.angRequires('afBlock'));
})(angular, CRM.$, CRM._);
<div>{{ts('Contact email block for a model of type %1 named %2', {1: afFieldset.getDefn().type, 2: afFieldset.getDefn().modelName})}}</div>
(function(angular, $, _) {
// Example usage: <div af-block-contact-email="{foo: 1, bar: 2}"></div>
angular.module('afBlock').directive('afBlockContactEmail', function() {
return {
restrict: 'AE',
require: ['^afFieldset'],
templateUrl: '~/afBlock/ContactEmail.html',
scope: {},
link: function($scope, $el, $attr, ctrls) {
var ts = $scope.ts = CRM.ts('afform');
$scope.afFieldset = ctrls[0];
}
};
});
})(angular, CRM.$, CRM._);
<af-form>
<af-entity name="$parent.model" type="Contact" label="Target Contact" />
<div af-fieldset="$parent.model">
<af-field name="prefix" />
<af-field name="first_name" />
<af-field name="last_name" />
</div>
</af-form>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment