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