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