CollectionTrait - Use "splats". Split out "adders". Define interfaces.
[civicrm-core.git] / CRM / Core / Resources / CollectionAdderTrait.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 * Class CRM_Core_Resources_CollectionTrait
14 *
15 * This is a building-block for creating classes which maintain a list of resources.
16 *
17 * The class is generally organized in two sections: First, we have core
18 * bit that manages a list of '$snippets'. Second, we have a set of helper
19 * functions which add some syntactic sugar for the snippets.
20 */
21 trait CRM_Core_Resources_CollectionAdderTrait {
22
23 /**
24 * Add an item to the collection.
25 *
26 * @param array $snippet
27 * @return array
28 * The full/computed snippet (with defaults applied).
29 * @see CRM_Core_Resources_CollectionInterface::add()
30 */
31 abstract public function add($snippet);
32
33 /**
34 * Locate the 'settings' snippet.
35 *
36 * @param array $options
37 * @return array
38 */
39 abstract protected function &findCreateSettingSnippet($options = []): array;
40
41 /**
42 * Export permission data to the client to enable smarter GUIs.
43 *
44 * Note: Application security stems from the server's enforcement
45 * of the security logic (e.g. in the API permissions). There's no way
46 * the client can use this info to make the app more secure; however,
47 * it can produce a better-tuned (non-broken) UI.
48 *
49 * @param string|iterable $permNames
50 * List of permission names to check/export.
51 * @return static
52 */
53 public function addPermissions($permNames) {
54 // TODO: Maybe this should be its own resource type to allow smarter management?
55 $permNames = is_scalar($permNames) ? [$permNames] : $permNames;
56
57 $perms = [];
58 foreach ($permNames as $permName) {
59 $perms[$permName] = CRM_Core_Permission::check($permName);
60 }
61 return $this->addSetting([
62 'permissions' => $perms,
63 ]);
64 }
65
66 /**
67 * Add a JavaScript file to the current page using <SCRIPT SRC>.
68 *
69 * @param string $code
70 * JavaScript source code.
71 * @param array $options
72 * Open-ended list of options (per add())
73 * Ex: ['weight' => 123]
74 * @return static
75 */
76 public function addScript(string $code, ...$options) {
77 $this->add(self::mergeStandardOptions($options, [
78 'script' => $code,
79 ]));
80 return $this;
81 }
82
83 /**
84 * Add a JavaScript file to the current page using <SCRIPT SRC>.
85 *
86 * Options may be use key-value format (preferred) or positional format (legacy).
87 *
88 * - addScriptFile('myext', 'my.js', ['weight' => 123, 'region' => 'page-footer'])
89 * - addScriptFile('myext', 'my.js', 123, 'page-footer')
90 *
91 * @param string $ext
92 * extension name; use 'civicrm' for core.
93 * @param string $file
94 * file path -- relative to the extension base dir.
95 * @param array $options
96 * Open-ended list of options (per add()).
97 * Ex: ['weight' => 123]
98 * Accepts some additional options:
99 * - bool|string $translate: Whether to load translated strings for this file. Use one of:
100 * - FALSE: Do not load translated strings.
101 * - TRUE: Load translated strings. Use the $ext's default domain.
102 * - string: Load translated strings. Use a specific domain.
103 *
104 * @return static
105 *
106 * @throws \CRM_Core_Exception
107 */
108 public function addScriptFile(string $ext, string $file, ...$options) {
109 $this->add(self::mergeStandardOptions($options, [
110 'scriptFile' => [$ext, $file],
111 ]));
112 return $this;
113 }
114
115 /**
116 * Add a JavaScript file to the current page using <SCRIPT SRC>.
117 *
118 * Options may be use key-value format (preferred) or positional format (legacy).
119 *
120 * - addScriptUrl('http://example.com/foo.js', ['weight' => 123, 'region' => 'page-footer'])
121 * - addScriptUrl('http://example.com/foo.js', 123, 'page-footer')
122 *
123 * @param string $url
124 * @param array $options
125 * Open-ended list of options (per add())
126 * @return static
127 */
128 public function addScriptUrl(string $url, ...$options) {
129 $this->add(self::mergeStandardOptions($options, [
130 'scriptUrl' => $url,
131 ]));
132 return $this;
133 }
134
135 /**
136 * Add translated string to the js CRM object.
137 * It can then be retrived from the client-side ts() function
138 * Variable substitutions can happen from client-side
139 *
140 * Note: this function rarely needs to be called directly and is mostly for internal use.
141 * See CRM_Core_Resources::addScriptFile which automatically adds translated strings from js files
142 *
143 * Simple example:
144 * // From php:
145 * CRM_Core_Resources::singleton()->addString('Hello');
146 * // The string is now available to javascript code i.e.
147 * ts('Hello');
148 *
149 * Example with client-side substitutions:
150 * // From php:
151 * CRM_Core_Resources::singleton()->addString('Your %1 has been %2');
152 * // ts() in javascript works the same as in php, for example:
153 * ts('Your %1 has been %2', {1: objectName, 2: actionTaken});
154 *
155 * NOTE: This function does not work with server-side substitutions
156 * (as this might result in collisions and unwanted variable injections)
157 * Instead, use code like:
158 * CRM_Core_Resources::singleton()->addSetting(array('myNamespace' => array('myString' => ts('Your %1 has been %2', array(subs)))));
159 * And from javascript access it at CRM.myNamespace.myString
160 *
161 * @param string|array $text
162 * @param string|null $domain
163 * @return static
164 */
165 public function addString($text, $domain = 'civicrm') {
166 // TODO: Maybe this should be its own resource type to allow smarter management?
167
168 foreach ((array) $text as $str) {
169 $translated = ts($str, [
170 'domain' => ($domain == 'civicrm') ? NULL : [$domain, NULL],
171 'raw' => TRUE,
172 ]);
173
174 // We only need to push this string to client if the translation
175 // is actually different from the original
176 if ($translated != $str) {
177 $bucket = $domain == 'civicrm' ? 'strings' : 'strings::' . $domain;
178 $this->addSetting([
179 $bucket => [$str => $translated],
180 ]);
181 }
182 }
183 return $this;
184 }
185
186 /**
187 * Add a CSS content to the current page using <STYLE>.
188 *
189 * @param string $code
190 * CSS source code.
191 * @param array $options
192 * Open-ended list of options (per add())
193 * Ex: ['weight' => 123]
194 * @return static
195 */
196 public function addStyle(string $code, ...$options) {
197 $this->add(self::mergeStandardOptions($options, [
198 'style' => $code,
199 ]));
200 return $this;
201 }
202
203 /**
204 * Add a CSS file to the current page using <LINK HREF>.
205 *
206 * @param string $ext
207 * extension name; use 'civicrm' for core.
208 * @param string $file
209 * file path -- relative to the extension base dir.
210 * @param array $options
211 * Open-ended list of options (per add())
212 * Ex: ['weight' => 123]
213 * @return static
214 */
215 public function addStyleFile(string $ext, string $file, ...$options) {
216 $this->add(self::mergeStandardOptions($options, [
217 'styleFile' => [$ext, $file],
218 ]));
219 return $this;
220 }
221
222 /**
223 * Add a CSS file to the current page using <LINK HREF>.
224 *
225 * @param string $url
226 * @param array $options
227 * Open-ended list of options (per add())
228 * Ex: ['weight' => 123]
229 * @return static
230 */
231 public function addStyleUrl(string $url, ...$options) {
232 $this->add(self::mergeStandardOptions($options, [
233 'styleUrl' => $url,
234 ]));
235 return $this;
236 }
237
238 /**
239 * Add JavaScript variables to the root of the CRM object.
240 * This function is usually reserved for low-level system use.
241 * Extensions and components should generally use addVars instead.
242 *
243 * @param array $settings
244 * Data to export.
245 * @param array $options
246 * Extra processing instructions on where/how to place the data.
247 * @return static
248 */
249 public function addSetting(array $settings, ...$options) {
250 $s = &$this->findCreateSettingSnippet($options);
251 $s['settings'] = self::mergeSettings($s['settings'], $settings);
252 return $this;
253 }
254
255 /**
256 * Add JavaScript variables to the global CRM object via a callback function.
257 *
258 * @param callable $callable
259 * @return static
260 */
261 public function addSettingsFactory($callable) {
262 $s = &$this->findCreateSettingSnippet();
263 $s['settingsFactories'][] = $callable;
264 return $this;
265 }
266
267 /**
268 * Add JavaScript variables to CRM.vars
269 *
270 * Example:
271 * From the server:
272 * CRM_Core_Resources::singleton()->addVars('myNamespace', array('foo' => 'bar'));
273 * Access var from javascript:
274 * CRM.vars.myNamespace.foo // "bar"
275 *
276 * @see https://docs.civicrm.org/dev/en/latest/standards/javascript/
277 *
278 * @param string $nameSpace
279 * Usually the name of your extension.
280 * @param array $vars
281 * @param array $options
282 * There are no supported options.
283 * @return static
284 */
285 public function addVars(string $nameSpace, array $vars, ...$options) {
286 $s = &$this->findCreateSettingSnippet($options);
287 $s['settings']['vars'][$nameSpace] = self::mergeSettings(
288 $s['settings']['vars'][$nameSpace] ?? [],
289 $vars
290 );
291 return $this;
292 }
293
294 /**
295 * Given the "$options" for "addScriptUrl()" (etal), normalize the contents
296 * and potentially add more.
297 *
298 * @param array $splats
299 * A list of options, as represented by the splat mechanism ("...$options").
300 * This may appear in one of two ways:
301 * - New (String Index): as in `addFoo($foo, array $options)`
302 * - Old (Numeric Index): as in `addFoo($foo, int $weight = X, string $region = Y, bool $translate = X)`
303 * @param array $defaults
304 * List of values to merge into $options.
305 * @return array
306 */
307 public static function mergeStandardOptions(array $splats, array $defaults = []) {
308 $count = count($splats);
309 switch ($count) {
310 case 0:
311 // Common+simple case: No splat options. We can short-circuit.
312 return $defaults;
313
314 case 1:
315 // Might be new format (key-value pairs) or old format
316 $parsed = is_array($splats[0]) ? $splats[0] : ['weight' => $splats[0]];
317 break;
318
319 case 2:
320 $parsed = ['weight' => $splats[0], 'region' => $splats[1]];
321 break;
322
323 case 3:
324 $parsed = ['weight' => $splats[0], 'region' => $splats[1], 'translate' => $splats[2]];
325 break;
326
327 default:
328 throw new \RuntimeException("Cannot resolve resource options. For clearest behavior, pass options in key-value format.");
329 }
330
331 return array_merge($defaults, $parsed);
332 }
333
334 /**
335 * Given the "$options" for "addSetting()" (etal), normalize the contents
336 * and potentially add more.
337 *
338 * @param array $splats
339 * A list of options, as represented by the splat mechanism ("...$options").
340 * This may appear in one of two ways:
341 * - New (String Index): as in `addFoo($foo, array $options)`
342 * - Old (Numeric Index): as in `addFoo($foo, int $weight = X, string $region = Y, bool $translate = X)`
343 * @param array $defaults
344 * List of values to merge into $options.
345 * @return array
346 */
347 public static function mergeSettingOptions(array $splats, array $defaults = []) {
348 $count = count($splats);
349 switch ($count) {
350 case 0:
351 // Common+simple case: No splat options. We can short-circuit.
352 return $defaults;
353
354 case 1:
355 // Might be new format (key-value pairs) or old format
356 $parsed = is_array($splats[0]) ? $splats[0] : ['region' => $splats[0]];
357 break;
358
359 default:
360 throw new \RuntimeException("Cannot resolve resource options. For clearest behavior, pass options in key-value format.");
361 }
362
363 return array_merge($defaults, $parsed);
364 }
365
366 /**
367 * @param array $settings
368 * @param array $additions
369 * @return array
370 * combination of $settings and $additions
371 */
372 public static function mergeSettings(array $settings, array $additions): array {
373 foreach ($additions as $k => $v) {
374 if (isset($settings[$k]) && is_array($settings[$k]) && is_array($v)) {
375 $v += $settings[$k];
376 }
377 $settings[$k] = $v;
378 }
379 return $settings;
380 }
381
382 }