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