Commit 4b1e2da8 authored by colemanw's avatar colemanw
Browse files

Refactor af-block directive into af-repeat and af-join

This allows mix-n-match between blocks and repeatable things, allowing
entity fieldsets to be repeated, as well as arbitrary non-repeatable blocks.
parent 0a411007
......@@ -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']) || isset($item['af-block'])) {
if ($item['#tag'] === 'af-field' || $item['#tag'] === 'af-form' || isset($item['af-fieldset']) || isset($item['af-join'])) {
return TRUE;
}
$editableClasses = ['af-container', 'af-text', 'af-button'];
......
......@@ -2,12 +2,13 @@
namespace Civi\Afform;
use Civi\Api4\Afform;
/**
* Class FormDataModel
* @package Civi\Afform
*
* The FormDataModel examines a form and determines the list of entities/fields
* which are used by the form.
* Examines a form and determines the entities, fields & joins in use.
*/
class FormDataModel {
......@@ -18,50 +19,58 @@ class FormDataModel {
protected $entities;
/**
* Gets entity metadata and all blocks & fields from the form
*
* @param array $layout
* The root element of the layout, in shallow/deep format.
* @return static
* Parsed summary of the entities used in a given form.
* @var array
*/
public static function create($layout) {
protected $blocks = [];
public function __construct($layout) {
$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]['blocks'] = [];
$this->entities = array_column(AHQ::getTags($root, 'af-entity'), NULL, 'name');
foreach (Afform::get()->setCheckPermissions(FALSE)->addSelect('name')->execute() as $block) {
$this->blocks[_afform_angular_module_name($block['name'], 'dash')] = $block;
}
self::parseFields($layout, $entities);
$self = new static();
$self->entities = $entities;
return $self;
foreach (array_keys($this->entities) as $entity) {
$this->entities[$entity]['fields'] = $this->entities[$entity]['joins'] = [];
}
$this->parseFields($layout);
}
/**
* @param array $nodes
* @param array $entities
* A list of entities, keyed by name.
* This will be updated to populate 'fields' and 'blocks'.
* Ex: $entities['spouse']['type'] = 'Contact';
* @param string $entity
* @param string $join
*/
protected static function parseFields($nodes, &$entities, $entity = NULL) {
protected function parseFields($nodes, $entity = NULL, $join = NULL) {
foreach ($nodes as $node) {
if (!is_array($node) || !isset($node['#tag'])) {
//nothing
continue;
}
elseif (!empty($node['af-fieldset'])) {
self::parseFields($node['#children'], $entities, $node['af-fieldset']);
elseif (!empty($node['af-fieldset']) && !empty($node['#children'])) {
$this->parseFields($node['#children'], $node['af-fieldset'], $join);
}
elseif ($entity && $node['#tag'] === 'af-field') {
$entities[$entity]['fields'][$node['name']] = AHQ::getProps($node);
if ($join) {
$this->entities[$entity]['joins'][$join]['fields'][$node['name']] = AHQ::getProps($node);
}
else {
$this->entities[$entity]['fields'][$node['name']] = AHQ::getProps($node);
}
}
elseif ($entity && !empty($node['af-block'])) {
$entities[$entity]['blocks'][$node['af-block']] = AHQ::getProps($node);
elseif ($entity && !empty($node['af-join'])) {
$this->entities[$entity]['joins'][$node['af-join']] = AHQ::getProps($node);
$this->parseFields($node['#children'] ?? [], $entity, $node['af-join']);
}
elseif (!empty($node['#children'])) {
self::parseFields($node['#children'], $entities, $entity);
$this->parseFields($node['#children'], $entity, $join);
}
// Recurse into embedded blocks
if (isset($this->blocks[$node['#tag']])) {
if (!isset($this->blocks[$node['#tag']]['layout'])) {
$this->blocks[$node['#tag']] = Afform::get()->setCheckPermissions(FALSE)->setSelect(['name', 'layout'])->addWhere('name', '=', $this->blocks[$node['#tag']]['name'])->execute()->first();
}
if (!empty($this->blocks[$node['#tag']]['layout'])) {
$this->parseFields($this->blocks[$node['#tag']]['layout'], $entity, $join);
}
}
}
}
......
......@@ -45,7 +45,7 @@ abstract class AbstractProcessor extends \Civi\Api4\Generic\AbstractAction {
}
}
$this->_formDataModel = FormDataModel::create($this->_afform['layout']);
$this->_formDataModel = new FormDataModel($this->_afform['layout']);
$this->validateArgs();
$result->exchangeArray($this->processForm());
}
......@@ -69,4 +69,31 @@ abstract class AbstractProcessor extends \Civi\Api4\Generic\AbstractAction {
*/
abstract protected function processForm();
/**
* @param $mainEntityName
* @param $joinEntityName
* @param $mainEntityId
* @return array
* @throws \API_Exception
*/
protected static function getJoinWhereClause($mainEntityName, $joinEntityName, $mainEntityId) {
$joinMeta = \Civi::$statics[__CLASS__][__FUNCTION__][$joinEntityName] ?? NULL;
$params = [];
if (!$joinMeta) {
$joinMeta = civicrm_api4($joinEntityName, 'getFields', ['checkPermissions' => FALSE, 'action' => 'create', 'select' => ['name']])->column('name');
\Civi::$statics[__CLASS__][__FUNCTION__][$joinEntityName] = $joinMeta;
}
if (in_array('entity_id', $joinMeta)) {
$params[] = ['entity_id', '=', $mainEntityId];
if (in_array('entity_table', $joinMeta)) {
$params[] = ['entity_table', '=', 'civicrm_' . _civicrm_api_get_entity_name_from_camel($mainEntityName)];
}
}
else {
$mainEntityField = _civicrm_api_get_entity_name_from_camel($mainEntityName) . '_id';
$params[] = [$mainEntityField, '=', $mainEntityId];
}
return $params;
}
}
......@@ -96,9 +96,9 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
'description' => '',
'is_public' => FALSE,
'permission' => 'access CiviCRM',
'block' => 'Custom_' . $custom['name'],
'join' => 'Custom_' . $custom['name'],
'extends' => 'Contact',
'repeatable' => TRUE,
'repeat' => TRUE,
'has_base' => TRUE,
];
if ($getLayout) {
......
......@@ -49,17 +49,17 @@ class Prefill extends AbstractProcessor {
'select' => array_keys($entity['fields']),
'checkPermissions' => $checkPermissions,
]);
$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,
foreach ($result as $item) {
$data = ['fields' => $item];
foreach ($entity['joins'] ?? [] as $joinEntity => $join) {
$data['joins'][$joinEntity] = (array) civicrm_api4($joinEntity, 'get', [
'where' => $this->getJoinWhereClause($entity['type'], $joinEntity, $item['id']),
'limit' => !empty($join['af-repeat']) ? $join['max'] ?? 0 : 1,
'select' => array_keys($join['fields']),
'checkPermissions' => $checkPermissions,
]);
}
$this->_data[$entity['name']] = $data;
$this->_data[$entity['name']][] = $data;
}
}
......@@ -68,6 +68,7 @@ class Prefill extends AbstractProcessor {
*
* @param $entity
* @param $mode
* @throws \API_Exception
*/
private function autoFillEntity($entity, $mode) {
$id = NULL;
......
......@@ -3,7 +3,6 @@
namespace Civi\Api4\Action\Afform;
use Civi\Afform\Event\AfformSubmitEvent;
use Civi\API\Exception\NotImplementedException;
/**
* Class Submit
......@@ -23,8 +22,15 @@ class Submit extends AbstractProcessor {
protected function processForm() {
$entityValues = [];
foreach ($this->_formDataModel->getEntities() as $entityName => $entity) {
// Predetermined values override submitted values
$entityValues[$entity['type']][$entityName] = ($entity['af-values'] ?? []) + ($this->values[$entityName] ?? []);
foreach ($this->values[$entityName] ?? [] as $values) {
$entityValues[$entity['type']][$entityName][] = $values + ['fields' => []];
// Predetermined values override submitted values
if (!empty($entity['af-values'])) {
foreach ($entityValues[$entity['type']][$entityName] as $index => $vals) {
$entityValues[$entity['type']][$entityName][$index]['fields'] = $entity['af-values'] + $vals['fields'];
}
}
}
}
$event = new AfformSubmitEvent($this->_formDataModel->getEntities(), $entityValues);
......@@ -44,29 +50,11 @@ class Submit extends AbstractProcessor {
* @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
}
}
public static function processContacts(AfformSubmitEvent $event) {
foreach ($event->entityValues['Contact'] ?? [] as $entityName => $contacts) {
foreach ($contacts as $contact) {
$saved = civicrm_api4('Contact', 'save', ['records' => [$contact['fields']]])->first();
self::saveJoins('Contact', $saved['id'], $contact['joins'] ?? []);
}
}
unset($event->entityValues['Contact']);
......@@ -78,23 +66,50 @@ class Submit extends AbstractProcessor {
* @see afform_civicrm_config
*/
public static function processGenericEntity(AfformSubmitEvent $event) {
foreach ($event->entityValues as $entityType => $records) {
civicrm_api4($entityType, 'save', [
'records' => $records,
]);
foreach ($event->entityValues as $entityType => $entities) {
// Each record is an array of one or more items (can be > 1 if af-repeat is used)
foreach ($entities as $entityName => $records) {
foreach ($records as $record) {
$saved = civicrm_api4($entityType, 'save', ['records' => [$record['fields']]])->first();
self::saveJoins($entityType, $saved['id'], $record['joins'] ?? []);
}
}
unset($event->entityValues[$entityType]);
}
}
protected static function saveJoins($mainEntityName, $entityId, $joins) {
foreach ($joins as $joinEntityName => $join) {
$values = self::filterEmptyJoins($joinEntityName, $join);
// FIXME: Replace/delete should only be done to known contacts
if ($values) {
civicrm_api4($joinEntityName, 'replace', [
'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId),
'records' => $values,
]);
}
else {
try {
civicrm_api4($joinEntityName, 'delete', [
'where' => self::getJoinWhereClause($mainEntityName, $joinEntityName, $entityId),
]);
}
catch (\API_Exception $e) {
// No records to delete
}
}
}
}
/**
* Filter out blocks that have been left blank on the form
* Filter out joins that have been left blank on the form
*
* @param $entity
* @param $block
* @param $join
* @return array
*/
private static function filterEmptyBlocks($entity, $block) {
return array_filter($block, function($item) use($entity) {
private static function filterEmptyJoins($entity, $join) {
return array_filter($join, function($item) use($entity) {
switch ($entity) {
case 'Email':
return !empty($item['email']);
......
......@@ -97,7 +97,7 @@ class Afform extends AbstractEntity {
'name' => 'block',
],
[
'name' => 'extends',
'name' => 'join',
],
[
'name' => 'title',
......@@ -111,8 +111,8 @@ class Afform extends AbstractEntity {
'data_type' => 'Boolean',
],
[
'name' => 'repeatable',
'data_type' => 'Boolean',
'name' => 'repeat',
'data_type' => 'Mixed',
],
[
'name' => 'server_route',
......
......@@ -322,12 +322,12 @@ function afform_civicrm_alterAngular($angular) {
->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
try {
$module = \Civi::service('angular')->getModule(basename($path, '.aff.html'));
$meta = \Civi\Api4\Afform::get()->addWhere('name', '=', $module['_afform'])->addSelect('block')->setCheckPermissions(FALSE)->execute()->first();
$meta = \Civi\Api4\Afform::get()->addWhere('name', '=', $module['_afform'])->setSelect(['join', 'block'])->setCheckPermissions(FALSE)->execute()->first();
}
catch (Exception $e) {
}
$blockEntity = $meta['block'] ?? NULL;
$blockEntity = $meta['join'] ?? $meta['block'] ?? NULL;
if (!$blockEntity) {
$entities = _afform_getMetadata($doc);
}
......@@ -335,12 +335,12 @@ function afform_civicrm_alterAngular($angular) {
foreach (pq('af-field', $doc) as $afField) {
/** @var DOMElement $afField */
$entityName = pq($afField)->parents('[af-fieldset]')->attr('af-fieldset');
$blockName = pq($afField)->parents('[af-block]')->attr('af-block');
$joinName = pq($afField)->parents('[af-join]')->attr('af-join');
if (!$blockEntity && !preg_match(';^[a-zA-Z0-9\_\-\. ]+$;', $entityName)) {
throw new \CRM_Core_Exception("Cannot process $path: malformed entity name ($entityName)");
}
$entityType = $blockEntity ?? $entities[$entityName]['type'];
_af_fill_field_metadata($blockName ? $blockName : $entityType, $afField);
_af_fill_field_metadata($joinName ? $joinName : $entityType, $afField);
}
});
$angular->add($fieldMetadata);
......
......@@ -18,8 +18,9 @@ return [
'af-entity' => 'E',
'af-fieldset' => 'A',
'af-form' => 'E',
'af-block' => 'A',
'af-block-item' => 'A',
'af-join' => 'A',
'af-repeat' => 'A',
'af-repeat-item' => 'A',
'af-field' => 'E',
],
];
(function(angular, $, _) {
var id = 0;
// Example usage: <div af-fieldset="myModel"><af-field name="do_not_email" /></div>
angular.module('af').directive('afField', function() {
return {
restrict: 'E',
require: ['^^afFieldset', '?^afBlockItem'],
require: ['^^afForm', '^^afFieldset', '?^^afJoin', '?^^afRepeatItem'],
templateUrl: '~/af/afField.html',
scope: {
fieldName: '@name',
......@@ -11,10 +12,10 @@
},
link: function($scope, $el, $attr, ctrls) {
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;
closestController = $($el).closest('[af-fieldset],[af-join],[af-repeat-item]'),
afForm = ctrls[0];
$scope.dataProvider = closestController.is('[af-repeat-item]') ? ctrls[3] : ctrls[2] || ctrls[1];
$scope.fieldId = afForm.getFormMeta().name + '-' + $scope.fieldName + '-' + id++;
$el.addClass('af-field-type-' + _.kebabCase($scope.defn.input_type));
......
......@@ -3,23 +3,30 @@
angular.module('af').directive('afFieldset', function() {
return {
restrict: 'A',
require: '^afForm',
scope: {
require: ['afFieldset', '^afForm'],
bindToController: {
modelName: '@afFieldset'
},
link: function($scope, $el, $attr, afFormCtrl) {
$scope.afFormCtrl = afFormCtrl;
link: function($scope, $el, $attr, ctrls) {
var self = ctrls[0];
self.afFormCtrl = ctrls[1];
},
controller: function($scope){
this.getDefn = function getDefn() {
return $scope.afFormCtrl.getEntity($scope.modelName);
// return $scope.modelDefn;
this.getDefn = function() {
return this.afFormCtrl.getEntity(this.modelName);
};
this.getData = function getData() {
return $scope.afFormCtrl.getData($scope.modelName);
this.getData = function() {
return this.afFormCtrl.getData(this.modelName);
};
this.getName = function() {
return $scope.modelName;
return this.modelName;
};
this.getFieldData = function() {
var data = this.getData();
if (!data.length) {
data.push({fields: {}});
}
return data[0].fields;
};
}
};
......
......@@ -20,7 +20,7 @@
this.registerEntity = function registerEntity(entity) {
schema[entity.modelName] = entity;
data[entity.modelName] = entity.data || {};
data[entity.modelName] = [];
};
this.getEntity = function getEntity(name) {
return schema[name];
......
(function(angular, $, _) {
// Example usage: <div af-join="Email" min="1" max="3" add-label="Add email" ><div join-email-default /></div>
angular.module('af')
.directive('afJoin', function() {
return {
restrict: 'A',
require: ['afJoin', '^^afFieldset', '?^^afRepeatItem'],
bindToController: {
entity: '@afJoin',
},
link: function($scope, $el, $attr, ctrls) {
var self = ctrls[0];
self.afFieldset = ctrls[1];
self.repeatItem = ctrls[2];
},
controller: function($scope) {
var self = this;
this.getData = function() {
var data, fieldsetData;
if (self.repeatItem) {
data = self.repeatItem.item;
} else {
fieldsetData = self.afFieldset.getData();
if (!fieldsetData.length) {
fieldsetData.push({fields: {}, joins: {}});
}
data = fieldsetData[0];
}
if (!data.joins) {
data.joins = {};
}
if (!data.joins[self.entity]) {
data.joins[self.entity] = [];
}
return data.joins[self.entity];
};
this.getFieldData = function() {
var data = this.getData();
if (!data.length) {
data.push({});
}
return data[0];
};
}
};
});
})(angular, CRM.$, CRM._);
(function(angular, $, _) {
// Example usage: <div af-block="Email" min="1" max="3" add-label="Add email" ><div block-email-default /></div>
// Example usage: <div af-repeat="Email" min="1" max="3" add-label="Add email" ><div repeat-email-default /></div>
angular.module('af')
.directive('afBlock', function() {
.directive('afRepeat', function() {
return {
restrict: 'A',
require: ['^^afFieldset'],
require: ['?afFieldset', '?afJoin'],
transclude: true,
scope: {
blockName: '@afBlock',
min: '=',
max: '=',
addLabel: '@',
addLabel: '@afRepeat',
addIcon: '@'
},
transclude: true,
templateUrl: '~/af/afBlock.html',
templateUrl: '~/af/afRepeat.html',
link: function($scope, $el, $attr, ctrls) {
var ts = $scope.ts = CRM.ts('afform');
$scope.afFieldset = ctrls[0];
$scope.afJoin = ctrls[1];
},
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({});
var data = $scope.afJoin ? $scope.afJoin.getData() : $scope.afFieldset.getData();
while ($scope.min && data.length < $scope.min) {
data.push(getRepeatType() === 'join' ? {} : {fields: {}, joins: {}});
}
return block;
return data;
};
function getRepeatType() {
return $scope.afJoin ? 'join' : 'fieldset';
}
this.getRepeatType = getRepeatType;
$scope.addItem = function() {
$scope.getItems().push({});
$scope.getItems().push(getRepeatType() === 'join' ? {} : {fields: {}});
};
$scope.removeItem = function(index) {
......@@ -47,15 +49,20 @@
}
};
})
.directive('afBlockItem', function() {
.directive('afRepeatItem', function() {
return {
restrict: 'A',
scope: {
item: '=afBlockItem'
require: ['afRepeatItem', '^^afRepeat'],
bindToController: {
item: '=afRepeatItem'
},
link: function($scope, $el, $attr, ctrls) {
var self = ctrls[0];
self.afRepeat = ctrls[1];
},
controller: function($scope) {
this.getData = function() {
return $scope