Implement afform blocks for multi-value custom groups
[civicrm-core.git] / ext / afform / core / afform.php
CommitLineData
66aa0f5e
TO
1<?php
2
3require_once 'afform.civix.php';
4use CRM_Afform_ExtensionUtil as E;
fb388832 5use Civi\Api4\Action\Afform\Submit;
66aa0f5e 6
bc3b7c5b
TO
7/**
8 * Filter the content of $params to only have supported afform fields.
9 *
10 * @param array $params
11 * @return array
12 */
13function _afform_fields_filter($params) {
5591cfbf 14 $result = [];
50868e8d
CW
15 $fields = \Civi\Api4\Afform::getfields()->setCheckPermissions(FALSE)->execute()->indexBy('name');
16 foreach ($fields as $fieldName => $field) {
17 if (isset($params[$fieldName])) {
18 $result[$fieldName] = $params[$fieldName];
bc3b7c5b 19 }
d1ec770c 20
50868e8d
CW
21 if (isset($result[$fieldName])) {
22 switch ($fieldName) {
d1ec770c 23 case 'is_public':
50868e8d 24 $result[$fieldName] = CRM_Utils_String::strtobool($result[$fieldName]);
d1ec770c
TO
25 break;
26
27 }
28 }
bc3b7c5b
TO
29 }
30 return $result;
31}
32
8f4a0ee9 33/**
5591cfbf 34 * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
8f4a0ee9
TO
35 */
36function afform_civicrm_container($container) {
77dccccb 37 $container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__));
8f4a0ee9
TO
38 $container->setDefinition('afform_scanner', new \Symfony\Component\DependencyInjection\Definition(
39 'CRM_Afform_AfformScanner',
5591cfbf 40 []
8f4a0ee9
TO
41 ));
42}
43
66aa0f5e
TO
44/**
45 * Implements hook_civicrm_config().
46 *
47 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_config
48 */
49function afform_civicrm_config(&$config) {
50 _afform_civix_civicrm_config($config);
77dccccb
TO
51
52 if (isset(Civi::$statics[__FUNCTION__])) {
53 return;
54 }
55 Civi::$statics[__FUNCTION__] = 1;
56
e1aca853 57 Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processContacts'], 500);
fb388832 58 Civi::dispatcher()->addListener(Submit::EVENT_NAME, [Submit::class, 'processGenericEntity'], -1000);
77dccccb 59 Civi::dispatcher()->addListener('hook_civicrm_angularModules', '_afform_civicrm_angularModules_autoReq', -1000);
66aa0f5e
TO
60}
61
62/**
63 * Implements hook_civicrm_xmlMenu().
64 *
65 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_xmlMenu
66 */
67function afform_civicrm_xmlMenu(&$files) {
68 _afform_civix_civicrm_xmlMenu($files);
69}
70
71/**
72 * Implements hook_civicrm_install().
73 *
74 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_install
75 */
76function afform_civicrm_install() {
77 _afform_civix_civicrm_install();
78}
79
80/**
81 * Implements hook_civicrm_postInstall().
82 *
83 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_postInstall
84 */
85function afform_civicrm_postInstall() {
86 _afform_civix_civicrm_postInstall();
87}
88
89/**
90 * Implements hook_civicrm_uninstall().
91 *
92 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_uninstall
93 */
94function afform_civicrm_uninstall() {
95 _afform_civix_civicrm_uninstall();
96}
97
98/**
99 * Implements hook_civicrm_enable().
100 *
101 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_enable
102 */
103function afform_civicrm_enable() {
104 _afform_civix_civicrm_enable();
105}
106
107/**
108 * Implements hook_civicrm_disable().
109 *
110 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_disable
111 */
112function afform_civicrm_disable() {
113 _afform_civix_civicrm_disable();
114}
115
116/**
117 * Implements hook_civicrm_upgrade().
118 *
119 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_upgrade
120 */
121function afform_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) {
122 return _afform_civix_civicrm_upgrade($op, $queue);
123}
124
125/**
126 * Implements hook_civicrm_managed().
127 *
128 * Generate a list of entities to create/deactivate/delete when this module
129 * is installed, disabled, uninstalled.
130 *
131 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_managed
132 */
133function afform_civicrm_managed(&$entities) {
134 _afform_civix_civicrm_managed($entities);
135}
136
137/**
138 * Implements hook_civicrm_caseTypes().
139 *
140 * Generate a list of case-types.
141 *
142 * Note: This hook only runs in CiviCRM 4.4+.
143 *
144 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_caseTypes
145 */
146function afform_civicrm_caseTypes(&$caseTypes) {
147 _afform_civix_civicrm_caseTypes($caseTypes);
148}
149
150/**
151 * Implements hook_civicrm_angularModules().
152 *
e1aca853 153 * Generate a list of Afform Angular modules.
66aa0f5e
TO
154 */
155function afform_civicrm_angularModules(&$angularModules) {
156 _afform_civix_civicrm_angularModules($angularModules);
bb56ac78 157
2d4bfef1
CW
158 $afforms = \Civi\Api4\Afform::get()
159 ->setCheckPermissions(FALSE)
160 ->setSelect(['name', 'requires'])
161 ->execute();
162
163 foreach ($afforms as $afform) {
164 $angularModules[_afform_angular_module_name($afform['name'], 'camel')] = [
bb56ac78 165 'ext' => E::LONG_NAME,
2d4bfef1
CW
166 'js' => ['assetBuilder://afform.js?name=' . urlencode($afform['name'])],
167 'requires' => $afform['requires'],
bb56ac78 168 'basePages' => [],
aa6abb77 169 'partialsCallback' => '_afform_get_partials',
2d4bfef1 170 '_afform' => $afform['name'],
77dccccb 171 'exports' => [
2d4bfef1 172 _afform_angular_module_name($afform['name'], 'dash') => 'AE',
77dccccb 173 ],
bb56ac78 174 ];
bb56ac78 175 }
66aa0f5e
TO
176}
177
aa6abb77 178/**
2d4bfef1
CW
179 * Callback to retrieve partials for a given afform/angular module.
180 *
181 * @see afform_civicrm_angularModules
aa6abb77
TO
182 *
183 * @param string $moduleName
184 * The module name.
185 * @param array $module
186 * The module definition.
187 * @return array
188 * Array(string $filename => string $html).
2d4bfef1 189 * @throws API_Exception
aa6abb77
TO
190 */
191function _afform_get_partials($moduleName, $module) {
2d4bfef1
CW
192 $afform = civicrm_api4('Afform', 'get', [
193 'where' => [['name', '=', $module['_afform']]],
194 'select' => ['layout'],
195 'layoutFormat' => 'html',
196 'checkPermissions' => FALSE,
197 ], 0);
aa6abb77 198 return [
2d4bfef1 199 "~/$moduleName/$moduleName.aff.html" => $afform['layout'],
aa6abb77
TO
200 ];
201}
202
77dccccb
TO
203/**
204 * Scan the list of Angular modules and inject automatic-requirements.
205 *
206 * TLDR: if an afform uses element "<other-el/>", and if another module defines
207 * `$angularModules['otherMod']['exports']['el'][0] === 'other-el'`, then
208 * the 'otherMod' is automatically required.
209 *
210 * @param \Civi\Core\Event\GenericHookEvent $e
211 * @see CRM_Utils_Hook::angularModules()
212 */
213function _afform_civicrm_angularModules_autoReq($e) {
214 /** @var CRM_Afform_AfformScanner $scanner */
215 $scanner = Civi::service('afform_scanner');
216 $moduleEnvId = md5(\CRM_Core_Config_Runtime::getId() . implode(',', array_keys($e->angularModules)));
217 $depCache = CRM_Utils_Cache::create([
218 'name' => 'afdep_' . substr($moduleEnvId, 0, 32 - 6),
219 'type' => ['*memory*', 'SqlGroup', 'ArrayCache'],
220 'withArray' => 'fast',
221 'prefetch' => TRUE,
222 ]);
223 $depCacheTtl = 2 * 60 * 60;
224
225 $revMap = _afform_reverse_deps($e->angularModules);
226
227 $formNames = array_keys($scanner->findFilePaths());
228 foreach ($formNames as $formName) {
229 $angModule = _afform_angular_module_name($formName, 'camel');
230 $cacheLine = $depCache->get($formName, NULL);
231
232 $jFile = $scanner->findFilePath($formName, 'aff.json');
233 $hFile = $scanner->findFilePath($formName, 'aff.html');
234
235 $jStat = stat($jFile);
236 $hStat = stat($hFile);
237
238 if ($cacheLine === NULL) {
239 $needsUpdate = TRUE;
240 }
241 elseif ($jStat !== FALSE && $jStat['size'] !== $cacheLine['js']) {
242 $needsUpdate = TRUE;
243 }
244 elseif ($jStat !== FALSE && $jStat['mtime'] > $cacheLine['jm']) {
245 $needsUpdate = TRUE;
246 }
247 elseif ($hStat !== FALSE && $hStat['size'] !== $cacheLine['hs']) {
248 $needsUpdate = TRUE;
249 }
250 elseif ($hStat !== FALSE && $hStat['mtime'] > $cacheLine['hm']) {
251 $needsUpdate = TRUE;
252 }
253 else {
254 $needsUpdate = FALSE;
255 }
256
257 if ($needsUpdate) {
258 $cacheLine = [
259 'js' => $jStat['size'] ?? NULL,
260 'jm' => $jStat['mtime'] ?? NULL,
261 'hs' => $hStat['size'] ?? NULL,
262 'hm' => $hStat['mtime'] ?? NULL,
263 'r' => array_values(array_unique(array_merge(
264 [CRM_Afform_AfformScanner::DEFAULT_REQUIRES],
265 $e->angularModules[$angModule]['requires'] ?? [],
266 _afform_reverse_deps_find($formName, file_get_contents($hFile), $revMap)
267 ))),
268 ];
269 // print_r(['cache update:' . $formName => $cacheLine]);
270 $depCache->set($formName, $cacheLine, $depCacheTtl);
271 }
272
273 $e->angularModules[$angModule]['requires'] = $cacheLine['r'];
274 }
275}
276
277/**
278 * @param $angularModules
279 * @return array
280 * 'attr': array(string $attrName => string $angModuleName)
281 * 'el': array(string $elementName => string $angModuleName)
282 */
283function _afform_reverse_deps($angularModules) {
23315411
TO
284 $revMap = ['attr' => [], 'el' => []];
285 foreach (array_keys($angularModules) as $module) {
286 if (!isset($angularModules[$module]['exports'])) {
287 continue;
288 }
289 foreach ($angularModules[$module]['exports'] as $symbolName => $symbolTypes) {
290 if (strpos($symbolTypes, 'A') !== FALSE) {
291 $revMap['attr'][$symbolName] = $module;
292 }
293 if (strpos($symbolTypes, 'E') !== FALSE) {
294 $revMap['el'][$symbolName] = $module;
77dccccb
TO
295 }
296 }
297 }
298 return $revMap;
299}
300
23315411
TO
301/**
302 * @param string $formName
303 * @param string $html
304 * @param array $revMap
305 * The reverse-dependencies map from _afform_reverse_deps().
306 * @return array
307 * @see _afform_reverse_deps()
308 */
77dccccb
TO
309function _afform_reverse_deps_find($formName, $html, $revMap) {
310 $symbols = \Civi\Afform\Symbols::scan($html);
311 $elems = array_intersect_key($revMap['el'], $symbols->elements);
312 $attrs = array_intersect_key($revMap['attr'], $symbols->attributes);
313 return array_values(array_unique(array_merge($elems, $attrs)));
314}
315
9384980c
TO
316/**
317 * @param \Civi\Angular\Manager $angular
318 * @see CRM_Utils_Hook::alterAngular()
319 */
320function afform_civicrm_alterAngular($angular) {
321 $fieldMetadata = \Civi\Angular\ChangeSet::create('fieldMetadata')
dd10599c 322 ->alterHtml(';\\.aff\\.html$;', function($doc, $path) {
9c84a124
CW
323 try {
324 $module = \Civi::service('angular')->getModule(basename($path, '.aff.html'));
325 $meta = \Civi\Api4\Afform::get()->addWhere('name', '=', $module['_afform'])->addSelect('block')->setCheckPermissions(FALSE)->execute()->first();
326 }
327 catch (Exception $e) {
328 }
329
e1aca853
CW
330 $blockEntity = $meta['block'] ?? NULL;
331 if (!$blockEntity) {
332 $entities = _afform_getMetadata($doc);
333 }
9384980c
TO
334
335 foreach (pq('af-field', $doc) as $afField) {
336 /** @var DOMElement $afField */
56b9319d 337 $entityName = pq($afField)->parents('[af-fieldset]')->attr('af-fieldset');
e1aca853
CW
338 $blockName = pq($afField)->parents('[af-block]')->attr('af-block');
339 if (!$blockEntity && !preg_match(';^[a-zA-Z0-9\_\-\. ]+$;', $entityName)) {
9384980c
TO
340 throw new \CRM_Core_Exception("Cannot process $path: malformed entity name ($entityName)");
341 }
e1aca853
CW
342 $entityType = $blockEntity ?? $entities[$entityName]['type'];
343 _af_fill_field_metadata($blockName ? $blockName : $entityType, $afField);
9384980c
TO
344 }
345 });
346 $angular->add($fieldMetadata);
347}
348
e1aca853
CW
349/**
350 * Merge field definition metadata into an afform field's definition
351 *
352 * @param $entityType
353 * @param DOMElement $afField
354 * @throws API_Exception
355 */
356function _af_fill_field_metadata($entityType, DOMElement $afField) {
357 $fieldName = $afField->getAttribute('name');
358 $getFields = civicrm_api4($entityType, 'getFields', [
359 'action' => 'create',
360 'where' => [['name', '=', $fieldName]],
361 'select' => ['title', 'input_type', 'input_attrs', 'options'],
362 'loadOptions' => TRUE,
363 ]);
364 // Merge field definition data with whatever's already in the markup
365 $deep = ['input_attrs'];
366 foreach ($getFields as $fieldInfo) {
367 $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
368 if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
369 // If it's not an object, don't mess with it.
370 continue;
371 }
372 // TODO: Teach the api to return options in this format
373 if (!empty($fieldInfo['options'])) {
374 $fieldInfo['options'] = CRM_Utils_Array::makeNonAssociative($fieldInfo['options'], 'key', 'label');
375 }
376 // Default placeholder for select inputs
377 if ($fieldInfo['input_type'] === 'Select') {
378 $fieldInfo['input_attrs'] = ($fieldInfo['input_attrs'] ?? []) + ['placeholder' => ts('Select')];
379 }
380
381 $fieldDefn = $existingFieldDefn ? CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
382 foreach ($fieldInfo as $name => $prop) {
383 // Merge array props 1 level deep
384 if (in_array($name, $deep) && !empty($fieldDefn[$name])) {
385 $fieldDefn[$name] = CRM_Utils_JS::writeObject(CRM_Utils_JS::getRawProps($fieldDefn[$name]) + array_map(['CRM_Utils_JS', 'encode'], $prop));
386 }
387 elseif (!isset($fieldDefn[$name])) {
388 $fieldDefn[$name] = CRM_Utils_JS::encode($prop);
389 }
390 }
391 pq($afField)->attr('defn', htmlspecialchars(CRM_Utils_JS::writeObject($fieldDefn)));
392 }
393}
394
9384980c
TO
395function _afform_getMetadata(phpQueryObject $doc) {
396 $entities = [];
c37151d2 397 foreach ($doc->find('af-entity') as $afmModelProp) {
8410442c 398 $entities[$afmModelProp->getAttribute('name')] = [
bbd6df21 399 'type' => $afmModelProp->getAttribute('type'),
9384980c
TO
400 ];
401 }
402 return $entities;
403}
404
66aa0f5e
TO
405/**
406 * Implements hook_civicrm_alterSettingsFolders().
407 *
408 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_alterSettingsFolders
409 */
410function afform_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) {
411 _afform_civix_civicrm_alterSettingsFolders($metaDataFolders);
412}
413
414/**
415 * Implements hook_civicrm_entityTypes().
416 *
417 * Declare entity types provided by this module.
418 *
419 * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_entityTypes
420 */
421function afform_civicrm_entityTypes(&$entityTypes) {
422 _afform_civix_civicrm_entityTypes($entityTypes);
423}
424
98f4a7cb
TO
425/**
426 * Implements hook_civicrm_themes().
427 */
428function afform_civicrm_themes(&$themes) {
429 _afform_civix_civicrm_themes($themes);
430}
431
bb56ac78
TO
432/**
433 * Implements hook_civicrm_buildAsset().
434 */
435function afform_civicrm_buildAsset($asset, $params, &$mimeType, &$content) {
436 if ($asset !== 'afform.js') {
437 return;
438 }
439
440 if (empty($params['name'])) {
441 throw new RuntimeException("Missing required parameter: afform.js?name=NAME");
442 }
443
2d4bfef1 444 $moduleName = _afform_angular_module_name($params['name'], 'camel');
bb56ac78
TO
445 $smarty = CRM_Core_Smarty::singleton();
446 $smarty->assign('afform', [
aa6abb77 447 'camel' => $moduleName,
2d4bfef1 448 'meta' => ['name' => $params['name']],
aa6abb77 449 'templateUrl' => "~/$moduleName/$moduleName.aff.html",
bb56ac78
TO
450 ]);
451 $mimeType = 'text/javascript';
9ec944f2 452 $content = $smarty->fetch('afform/AfformAngularModule.tpl');
bb56ac78
TO
453}
454
8775c48a
TO
455/**
456 * Implements hook_civicrm_alterMenu().
457 */
458function afform_civicrm_alterMenu(&$items) {
8f4a0ee9
TO
459 if (Civi::container()->has('afform_scanner')) {
460 $scanner = Civi::service('afform_scanner');
461 }
462 else {
463 // During installation...
464 $scanner = new CRM_Afform_AfformScanner();
465 }
8775c48a
TO
466 foreach ($scanner->getMetas() as $name => $meta) {
467 if (!empty($meta['server_route'])) {
468 $items[$meta['server_route']] = [
469 'page_callback' => 'CRM_Afform_Page_AfformBase',
470 'page_arguments' => 'afform=' . urlencode($name),
13bdd6d2 471 'title' => $meta['title'] ?? '',
f16b2aee 472 'access_arguments' => [["@afform:$name"], 'and'],
254f01f0 473 'is_public' => $meta['is_public'],
8775c48a
TO
474 ];
475 }
476 }
f16b2aee
TO
477}
478
479/**
480 * Implements hook_civicrm_permission_check().
481 *
586344a7
TO
482 * This extends the list of permissions available in `CRM_Core_Permission:check()`
483 * by introducing virtual-permissions named `@afform:myForm`. The evaluation
484 * of these virtual-permissions is dependent on the settings for `myForm`.
485 * `myForm` may be exposed/integrated through multiple subsystems (routing,
486 * nav-menu, API, etc), and the use of virtual-permissions makes easy to enforce
487 * consistent permissions across any relevant subsystems.
488 *
f16b2aee
TO
489 * @see CRM_Utils_Hook::permission_check()
490 */
491function afform_civicrm_permission_check($permission, &$granted, $contactId) {
492 if ($permission{0} !== '@') {
493 // Micro-optimization - this function may get hit a lot.
494 return;
495 }
496
497 if (preg_match('/^@afform:(.*)/', $permission, $m)) {
498 $name = $m[1];
499
2d4bfef1
CW
500 $afform = \Civi\Api4\Afform::get()
501 ->setCheckPermissions(FALSE)
502 ->addWhere('name', '=', $name)
503 ->setSelect(['permission'])
504 ->execute()
505 ->first();
506 if ($afform) {
507 $granted = CRM_Core_Permission::check($afform['permission'], $contactId);
508 }
f16b2aee 509 }
8775c48a
TO
510}
511
74f862e4
TO
512/**
513 * Clear any local/in-memory caches based on afform data.
514 */
515function _afform_clear() {
516 $container = \Civi::container();
517 $container->get('afform_scanner')->clear();
518
519 // Civi\Angular\Manager doesn't currently have a way to clear its in-memory
520 // data, so we just reset the whole object.
521 $container->set('angular', NULL);
522}
523
bb56ac78 524/**
87dde5eb
TO
525 * @param string $fileBaseName
526 * Ex: foo-bar
841850b1
TO
527 * @param string $format
528 * 'camel' or 'dash'.
bb56ac78 529 * @return string
841850b1 530 * Ex: 'FooBar' or 'foo-bar'.
3cd5c38b 531 * @throws \Exception
bb56ac78 532 */
87dde5eb 533function _afform_angular_module_name($fileBaseName, $format = 'camel') {
841850b1
TO
534 switch ($format) {
535 case 'camel':
87dde5eb 536 $camelCase = '';
9c84a124 537 foreach (preg_split('/[-_ ]/', $fileBaseName, NULL, PREG_SPLIT_NO_EMPTY) as $shortNamePart) {
87dde5eb
TO
538 $camelCase .= ucfirst($shortNamePart);
539 }
540 return strtolower($camelCase{0}) . substr($camelCase, 1);
841850b1
TO
541
542 case 'dash':
9c84a124 543 return strtolower(implode('-', preg_split('/[-_ ]|(?=[A-Z])/', $fileBaseName, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE)));
841850b1
TO
544
545 default:
546 throw new \Exception("Unrecognized format");
547 }
bb56ac78 548}