Merge pull request #22354 from civicrm/5.45
[civicrm-core.git] / Civi / Angular / AngularLoader.php
1 <?php
2 namespace Civi\Angular;
3
4 /**
5 * The AngularLoader loads any JS/CSS/JSON resources
6 * required for setting up AngularJS.
7 *
8 * This class is returned by 'angularjs.loader' service. Example use:
9 *
10 * ```
11 * Civi::service('angularjs.loader')
12 * ->addModules('moduleFoo')
13 * ->useApp(); // Optional, if Civi's routing is desired (full-page apps only)
14 * ```
15 *
16 * @link https://docs.angularjs.org/guide/bootstrap
17 */
18 class AngularLoader {
19
20 /**
21 * The weight to assign to any Angular JS module files.
22 */
23 const DEFAULT_MODULE_WEIGHT = 200;
24
25 /**
26 * The resource manager.
27 *
28 * Do not use publicly. Inject your own copy!
29 *
30 * @var \CRM_Core_Resources
31 */
32 protected $res;
33
34 /**
35 * The Angular module manager.
36 *
37 * Do not use publicly. Inject your own copy!
38 *
39 * @var \Civi\Angular\Manager
40 */
41 protected $angular;
42
43 /**
44 * The region of the page into which JavaScript will be loaded.
45 *
46 * @var string
47 */
48 protected $region;
49
50 /**
51 * @var string
52 * Ex: 'civicrm/a'.
53 */
54 protected $pageName;
55
56 /**
57 * @var array
58 * A list of modules to load.
59 */
60 protected $modules;
61
62 /**
63 * @var array|null
64 */
65 protected $crmApp = NULL;
66
67 /**
68 * AngularLoader constructor.
69 */
70 public function __construct() {
71 $this->res = \CRM_Core_Resources::singleton();
72 $this->angular = \Civi::service('angular');
73 $this->region = \CRM_Utils_Request::retrieve('snippet', 'String') ? 'ajax-snippet' : 'html-header';
74 $this->pageName = \CRM_Utils_System::currentPath();
75 $this->modules = [];
76 }
77
78 /**
79 * Calling this method from outside this class is deprecated.
80 *
81 * Use the `angularjs.loader` service instead.
82 *
83 * @deprecated
84 * @return $this
85 */
86 public function load() {
87 \CRM_Core_Error::deprecatedFunctionWarning('angularjs.loader service');
88 return $this->loadAngularResources();
89 }
90
91 /**
92 * Load scripts, styles & settings for the active modules.
93 *
94 * @return $this
95 * @throws \CRM_Core_Exception
96 */
97 private function loadAngularResources() {
98 $angular = $this->getAngular();
99 $res = $this->getRes();
100
101 if ($this->crmApp !== NULL) {
102 $this->addModules($this->crmApp['modules']);
103
104 $this->res->addSetting([
105 'crmApp' => [
106 'defaultRoute' => $this->crmApp['defaultRoute'],
107 ],
108 ]);
109
110 // If trying to load an Angular page via AJAX, the route must be passed as a
111 // URL parameter, since the server doesn't receive information about
112 // URL fragments (i.e, what comes after the #).
113 $this->res->addSetting([
114 'angularRoute' => $this->crmApp['activeRoute'],
115 ]);
116 }
117
118 $moduleNames = $this->findActiveModules();
119 if (!$this->isAllModules($moduleNames)) {
120 $assetParams = ['modules' => implode(',', $moduleNames)];
121 }
122 else {
123 // The module list will be "all modules that the user can see".
124 $assetParams = ['nonce' => md5(implode(',', $moduleNames))];
125 }
126
127 $res->addSettingsFactory(function () use (&$moduleNames, $angular, $res, $assetParams) {
128 // Merge static settings with the results of settingsFactory functions
129 $settingsByModule = $angular->getResources($moduleNames, 'settings', 'settings');
130 foreach ($angular->getResources($moduleNames, 'settingsFactory', 'settingsFactory') as $moduleName => $factory) {
131 $settingsByModule[$moduleName] = array_merge($settingsByModule[$moduleName] ?? [], $factory());
132 }
133 // Add clientside permissions
134 $permissions = [];
135 $toCheck = $angular->getResources($moduleNames, 'permissions', 'permissions');
136 foreach ($toCheck as $perms) {
137 foreach ((array) $perms as $perm) {
138 if (!isset($permissions[$perm])) {
139 $permissions[$perm] = \CRM_Core_Permission::check($perm);
140 }
141 }
142 }
143 // TODO optimization; client-side caching
144 return array_merge($settingsByModule, ['permissions' => $permissions], [
145 'resourceUrls' => \CRM_Extension_System::singleton()->getMapper()->getActiveModuleUrls(),
146 'angular' => [
147 'modules' => $moduleNames,
148 'requires' => $angular->getResources($moduleNames, 'requires', 'requires'),
149 'cacheCode' => $res->getCacheCode(),
150 'bundleUrl' => \Civi::service('asset_builder')->getUrl('angular-modules.json', $assetParams),
151 ],
152 ]);
153 });
154
155 $res->addScriptFile('civicrm', 'bower_components/angular/angular.min.js', 100, $this->getRegion(), FALSE);
156
157 $headOffset = 0;
158 $config = \CRM_Core_Config::singleton();
159 if ($config->debug) {
160 // FIXME: The `resetLocationProviderHashPrefix.js` has to stay in sync with `\Civi\Angular\Page\Modules::buildAngularModules()`.
161 $res->addScriptFile('civicrm', 'ang/resetLocationProviderHashPrefix.js', 101, $this->getRegion(), FALSE);
162 foreach ($moduleNames as $moduleName) {
163 foreach ($this->angular->getResources($moduleName, 'css', 'cacheUrl') as $url) {
164 $res->addStyleUrl($url, self::DEFAULT_MODULE_WEIGHT + (++$headOffset), $this->getRegion());
165 }
166 foreach ($this->angular->getResources($moduleName, 'js', 'cacheUrl') as $url) {
167 $res->addScriptUrl($url, self::DEFAULT_MODULE_WEIGHT + (++$headOffset), $this->getRegion());
168 // addScriptUrl() bypasses the normal string-localization of addScriptFile(),
169 // but that's OK because all Angular strings (JS+HTML) will load via crmResource.
170 }
171 }
172 }
173 else {
174 // Note: addScriptUrl() bypasses the normal string-localization of addScriptFile(),
175 // but that's OK because all Angular strings (JS+HTML) will load via crmResource.
176 // $aggScriptUrl = \CRM_Utils_System::url('civicrm/ajax/angular-modules', 'format=js&r=' . $res->getCacheCode(), FALSE, NULL, FALSE);
177 $aggScriptUrl = \Civi::service('asset_builder')->getUrl('angular-modules.js', $assetParams);
178 $res->addScriptUrl($aggScriptUrl, 120, $this->getRegion());
179
180 // FIXME: The following CSS aggregator doesn't currently handle path-adjustments - which can break icons.
181 //$aggStyleUrl = \CRM_Utils_System::url('civicrm/ajax/angular-modules', 'format=css&r=' . $res->getCacheCode(), FALSE, NULL, FALSE);
182 //$aggStyleUrl = \Civi::service('asset_builder')->getUrl('angular-modules.css', $assetParams);
183 //$res->addStyleUrl($aggStyleUrl, 120, $this->getRegion());
184
185 foreach ($this->angular->getResources($moduleNames, 'css', 'cacheUrl') as $url) {
186 $res->addStyleUrl($url, self::DEFAULT_MODULE_WEIGHT + (++$headOffset), $this->getRegion());
187 }
188 }
189 // Add bundles
190 foreach ($this->angular->getResources($moduleNames, 'bundles', 'bundles') as $bundles) {
191 $res->addBundle($bundles);
192 }
193
194 return $this;
195 }
196
197 /**
198 * Use Civi's generic "application" module.
199 *
200 * This is suitable for use on a basic, standalone Angular page
201 * like `civicrm/a`. (If you need to integrate Angular with pre-existing,
202 * non-Angular pages... then this probably won't help.)
203 *
204 * The Angular bootstrap process requires an HTML directive like
205 * `<div ng-app="foo">`.
206 *
207 * Calling useApp() will replace the page's main body with the
208 * `<div ng-app="crmApp">...</div>` and apply some configuration options
209 * for the `crmApp` module.
210 *
211 * @param array $settings
212 * A list of settings. Accepted values:
213 * - activeRoute: string, the route to open up immediately
214 * Ex: '/case/list'
215 * - defaultRoute: string, use this to redirect the default route (`/`) to another page
216 * Ex: '/case/list'
217 * - region: string, the place on the page where we should insert the angular app
218 * Ex: 'page-body'
219 * @return AngularLoader
220 * @link https://code.angularjs.org/1.5.11/docs/guide/bootstrap
221 */
222 public function useApp($settings = []) {
223 $defaults = [
224 'modules' => ['crmApp'],
225 'activeRoute' => NULL,
226 'defaultRoute' => NULL,
227 'region' => 'page-body',
228 'file' => 'Civi/Angular/Page/Main.tpl',
229 ];
230 $this->crmApp = array_merge($defaults, $settings);
231 $region = \CRM_Core_Region::instance($this->crmApp['region']);
232 $region->update('default', ['disabled' => TRUE]);
233 $region->add(['template' => $this->crmApp['file'], 'weight' => 0]);
234 return $this;
235 }
236
237 /**
238 * Get a list of all Angular modules which should be activated on this
239 * page.
240 *
241 * @return array
242 * List of module names.
243 * Ex: array('angularFileUpload', 'crmUi', 'crmUtil').
244 */
245 public function findActiveModules() {
246 return $this->angular->resolveDependencies(array_merge(
247 $this->getModules(),
248 $this->angular->resolveDefaultModules($this->getPageName())
249 ));
250 }
251
252 /**
253 * @param $moduleNames
254 * @return int
255 */
256 private function isAllModules($moduleNames) {
257 $allModuleNames = array_keys($this->angular->getModules());
258 return count(array_diff($allModuleNames, $moduleNames)) === 0;
259 }
260
261 /**
262 * @return \CRM_Core_Resources
263 */
264 public function getRes() {
265 return $this->res;
266 }
267
268 /**
269 * @param \CRM_Core_Resources $res
270 * @return AngularLoader
271 */
272 public function setRes($res) {
273 $this->res = $res;
274 return $this;
275 }
276
277 /**
278 * @return \Civi\Angular\Manager
279 */
280 public function getAngular() {
281 return $this->angular;
282 }
283
284 /**
285 * @param \Civi\Angular\Manager $angular
286 * @return AngularLoader
287 */
288 public function setAngular($angular) {
289 $this->angular = $angular;
290 return $this;
291 }
292
293 /**
294 * @return string
295 */
296 public function getRegion() {
297 return $this->region;
298 }
299
300 /**
301 * @param string $region
302 * @return AngularLoader
303 */
304 public function setRegion($region) {
305 $this->region = $region;
306 return $this;
307 }
308
309 /**
310 * @return string
311 * Ex: 'civicrm/a'.
312 */
313 public function getPageName() {
314 return $this->pageName;
315 }
316
317 /**
318 * @param string $pageName
319 * Ex: 'civicrm/a'.
320 * @return AngularLoader
321 */
322 public function setPageName($pageName) {
323 $this->pageName = $pageName;
324 return $this;
325 }
326
327 /**
328 * @param array|string $modules
329 * @return AngularLoader
330 */
331 public function addModules($modules) {
332 $modules = (array) $modules;
333 $this->modules = array_unique(array_merge($this->modules, $modules));
334 return $this;
335 }
336
337 /**
338 * @return array
339 */
340 public function getModules() {
341 return $this->modules;
342 }
343
344 /**
345 * Replace all previously set modules.
346 *
347 * Use with caution, as it can cause conflicts with other extensions who have added modules.
348 * @internal
349 * @deprecated
350 * @param array $modules
351 * @return AngularLoader
352 */
353 public function setModules($modules) {
354 \CRM_Core_Error::deprecatedFunctionWarning('addModules');
355 $this->modules = $modules;
356 return $this;
357 }
358
359 /**
360 * Loader service callback when rendering a page region.
361 *
362 * Loads Angular resources if any modules have been requested for this page.
363 *
364 * @param \Civi\Core\Event\GenericHookEvent $e
365 */
366 public function onRegionRender($e) {
367 if ($e->region->_name === $this->region && ($this->modules || $this->crmApp)) {
368 $this->loadAngularResources();
369 $this->res->addScriptFile('civicrm', 'js/crm-angularjs-loader.js', 200, $this->getRegion(), FALSE);
370 }
371 }
372
373 }