Commit 760aa9a2 authored by colemanw's avatar colemanw
Browse files

Merge branch 'blocks' into 'master'

Implement Blocks for contact-related entities

See merge request extensions/afform!20
parents 719ac93d f39497ed
......@@ -148,7 +148,13 @@ class CRM_Afform_AfformScanner {
}
}
public function getComputedFields($name) {
/**
* Adds has_local & has_base to an afform metadata record
*
* @param array $record
*/
public function addComputedFields(&$record) {
$name = $record['name'];
// Ex: $allPaths['viewIndividual'][0] == '/var/www/foo/afform/view-individual'].
$allPaths = $this->findFilePaths()[$name];
// $activeLayoutPath = $this->findFilePath($name, self::LAYOUT_FILE);
......@@ -156,11 +162,11 @@ class CRM_Afform_AfformScanner {
$localLayoutPath = $this->createSiteLocalPath($name, self::LAYOUT_FILE);
$localMetaPath = $this->createSiteLocalPath($name, self::METADATA_FILE);
$fields = [];
$fields['has_local'] = file_exists($localLayoutPath) || file_exists($localMetaPath);
$fields['has_base'] = ($fields['has_local'] && count($allPaths) > 1)
|| (!$fields['has_local'] && count($allPaths) > 0);
return $fields;
$record['has_local'] = file_exists($localLayoutPath) || file_exists($localMetaPath);
if (!isset($record['has_base'])) {
$record['has_base'] = ($record['has_local'] && count($allPaths) > 1)
|| (!$record['has_local'] && count($allPaths) > 0);
}
}
/**
......
......@@ -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-join'])) {
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 = [];
......
......@@ -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,47 +19,58 @@ class FormDataModel {
protected $entities;
/**
* Gets entity metadata and all 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'] = [];
$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($root, $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 $element
* The root element of the layout, in shallow/deep format.
* @param array $entities
* A list of entities, keyed by named.
* This will be updated to include 'fields'.
* Ex: $entities['spouse']['type'] = 'Contact';
* @param array $nodes
* @param string $entity
* @param string $join
*/
protected static function parseFields($element, &$entities) {
if (!isset($element['#children'])) {
return;
}
foreach ($element['#children'] as $child) {
if (is_string($child) || isset($child['#text'])) {
//nothing
protected function parseFields($nodes, $entity = NULL, $join = NULL) {
foreach ($nodes as $node) {
if (!is_array($node) || !isset($node['#tag'])) {
continue;
}
elseif (!empty($node['af-fieldset']) && !empty($node['#children'])) {
$this->parseFields($node['#children'], $node['af-fieldset'], $join);
}
elseif ($entity && $node['#tag'] === 'af-field') {
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-join'])) {
$this->entities[$entity]['joins'][$node['af-join']] = AHQ::getProps($node);
$this->parseFields($node['#children'] ?? [], $entity, $node['af-join']);
}
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['#children'])) {
$this->parseFields($node['#children'], $entity, $join);
}
else {
self::parseFields($child, $entities);
// 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);
}
}
}
}
......
......@@ -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);
......@@ -41,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());
}
......@@ -65,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;
}
}
......@@ -2,6 +2,9 @@
namespace Civi\Api4\Action\Afform;
use Civi\Api4\CustomField;
use Civi\Api4\CustomGroup;
/**
* @inheritDoc
* @package Civi\Api4\Action\Afform
......@@ -13,23 +16,100 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
public function getRecords() {
/** @var \CRM_Afform_AfformScanner $scanner */
$scanner = \Civi::service('afform_scanner');
$getComputed = $this->_isFieldSelected('has_local') || $this->_isFieldSelected('has_base');
$getLayout = $this->_isFieldSelected('layout');
$toGet = $this->_itemsToGet('name');
$names = $toGet ?? array_keys($scanner->findFilePaths());
$names = $this->_itemsToGet('name') ?? array_keys($scanner->findFilePaths());
$values = $this->getAutoGenerated($names, $toGet, $getLayout);
$values = [];
foreach ($names as $name) {
$record = $scanner->getMeta($name);
if ($record && ($this->_isFieldSelected('has_local') || $this->_isFieldSelected('has_base'))) {
$record = array_merge($record, $scanner->getComputedFields($name));
if (!$record && !isset($values[$name])) {
continue;
}
$values[$name] = array_merge($values[$name] ?? [], $record ?? []);
if ($getComputed) {
$scanner->addComputedFields($values[$name]);
}
$layout = $this->_isFieldSelected('layout') ? $scanner->getLayout($name) : NULL;
if ($layout !== NULL) {
// FIXME check for validity?
$record['layout'] = $this->convertHtmlToOutput($layout);
if ($getLayout) {
$values[$name]['layout'] = $scanner->getLayout($name) ?? $values[$name]['layout'] ?? '';
}
$values[] = $record;
}
if ($getLayout && $this->layoutFormat !== 'html') {
foreach ($values as $name => $record) {
$values[$name]['layout'] = $this->convertHtmlToOutput($record['layout']);
}
}
return $values;
}
/**
* Generates afform blocks from custom field sets.
*
* @param $names
* @param $toGet
* @param $getLayout
* @return array
* @throws \API_Exception
*/
protected function getAutoGenerated(&$names, $toGet, $getLayout) {
$values = $groupNames = [];
foreach ($toGet ?? [] as $name) {
if (strpos($name, 'blockCustom_') === 0 && strlen($name) > 12) {
$groupNames[] = substr($name, 12);
}
}
if ($toGet && !$groupNames) {
return $values;
}
$customApi = CustomGroup::get()
->setCheckPermissions(FALSE)
->setSelect(['name', 'title', 'help_pre', 'help_post'])
->addWhere('is_multiple', '=', 1)
->addWhere('is_active', '=', 1);
if ($groupNames) {
$customApi->addWhere('name', 'IN', $groupNames);
}
if ($getLayout) {
$customApi->addSelect('help_pre')->addSelect('help_post');
$customApi->addChain('fields', CustomField::get()
->setCheckPermissions(FALSE)
->addSelect('name')
->addWhere('custom_group_id', '=', '$id')
->addWhere('is_active', '=', 1)
->addOrderBy('weight', 'ASC')
);
}
foreach ($customApi->execute() as $custom) {
$name = 'blockCustom_' . $custom['name'];
if (!in_array($name, $names)) {
$names[] = $name;
}
$item = [
'name' => $name,
'requires' => [],
'title' => ts('%1 block (default)', [1 => $custom['title']]),
'description' => '',
'is_public' => FALSE,
'permission' => 'access CiviCRM',
'join' => 'Custom_' . $custom['name'],
'extends' => 'Contact',
'repeat' => TRUE,
'has_base' => TRUE,
];
if ($getLayout) {
$item['layout'] = ($custom['help_pre'] ? '<div class="af-markup">' . $custom['help_pre'] . "</div>\n" : '');
foreach ($custom['fields'] as $field) {
$item['layout'] .= "<af-field name=\"{$field['name']}\" />\n";
}
$item['layout'] .= ($custom['help_post'] ? '<div class="af-markup">' . $custom['help_post'] . "</div>\n" : '');
}
$values[$name] = $item;
}
return $values;
}
......
......@@ -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();
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;
}
}
......@@ -59,6 +68,7 @@ class Prefill extends AbstractProcessor {
*
* @param $entity
* @param $mode
* @throws \API_Exception
*/
private function autoFillEntity($entity, $mode) {
$id = NULL;
......
......@@ -22,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);
......@@ -38,31 +45,89 @@ 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 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']);
}
/**
* @param \Civi\Afform\Event\AfformSubmitEvent $event
* @throws \API_Exception
* @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 joins that have been left blank on the form
*
* @param $entity
* @param $join
* @return array
*/
private static function filterEmptyJoins($entity, $join) {
return array_filter($join, 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' => 'join',
],
[
'name' => 'title',
'required' => $self->getAction() === 'create',
......@@ -104,6 +110,10 @@ class Afform extends AbstractEntity {
'name' => 'is_public',
'data_type' => 'Boolean',
],
[
'name' => 'repeat',
'data_type' => 'Mixed',
],
[
'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,78 @@ 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);
try {
$module = \Civi::service('angular')->getModule(basename($path, '.aff.html'));
$meta = \Civi\Api4\Afform::get()->addWhere('name', '=', $module['_afform'])->setSelect(['join', 'block'])->setCheckPermissions(FALSE)->execute()->first();
}
catch (Exception $e) {
}
$blockEntity = $meta['join'] ?? $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)) {
$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 = $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<