MenuHelper::_prepareOptions()   B
last analyzed

Complexity

Conditions 6
Paths 3

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 17
c 0
b 0
f 0
nc 3
nop 1
dl 0
loc 26
rs 8.439
1
<?php
2
/**
3
 * Licensed under The GPL-3.0 License
4
 * For full copyright and license information, please see the LICENSE.txt
5
 * Redistributions of files must retain the above copyright notice.
6
 *
7
 * @since    2.0.0
8
 * @author   Christopher Castro <[email protected]>
9
 * @link     http://www.quickappscms.org
10
 * @license  http://opensource.org/licenses/gpl-3.0.html GPL-3.0 License
11
 */
12
namespace Menu\View\Helper;
13
14
use Cake\Datasource\EntityInterface;
15
use Cake\Error\FatalErrorException;
16
use Cake\ORM\Entity;
17
use Cake\ORM\TableRegistry;
18
use Cake\Utility\Hash;
19
use Cake\Utility\Inflector;
20
use Cake\View\StringTemplateTrait;
21
use Cake\View\View;
22
use CMS\Core\Plugin;
23
use CMS\View\Helper;
24
25
/**
26
 * Menu helper.
27
 *
28
 * Renders nested database records into a well formated `<ul>` menus
29
 * suitable for HTML pages.
30
 */
31
class MenuHelper extends Helper
32
{
33
34
    use StringTemplateTrait;
35
36
    /**
37
     * Default configuration for this class.
38
     *
39
     * - `formatter`: Callable method used when formating each item. Function should
40
     *   expect two arguments: $item and $info respectively.
41
     * - `beautify`: Set to true to "beautify" the resulting HTML, compacted HTMl will
42
     *    be returned if set to FALSE. You can set this option to a string compatible with
43
     *    [htmLawed](http://www.bioinformatics.org/phplabware/internal_utilities/htmLawed/htmLawed_README.htm) library.
44
     *    e.g: `2s0n`. Defaults to FALSE (compact).
45
     * - `dropdown`: Set to true to automatically apply a few CSS styles for creating a
46
     *    "Dropdown" menu. Defaults to FALSE. This option is useful when rendering
47
     *    Multi-level menus, such as site's "main menu", etc.
48
     * - `activeClass`: CSS class to use when an item is active (its URL matches current URL).
49
     * - `firstItemClass`: CSS class for the first item.
50
     * - `lastItemClass`: CSS class for the last item.
51
     * - `hasChildrenClass`: CSS class to use when an item has children.
52
     * - `split`: Split menu into multiple root menus (multiple UL's). Must be an integer,
53
     *    or false for no split (by default).
54
     * - `breadcrumbGuessing`: Whether to mark an item as "active" if its URL is on
55
     *   the breadcrumb stack. Default to true.
56
     * - `templates`: HTML templates used when formating items.
57
     *   - `div`: Template of the wrapper element which holds all menus when using `split`.
58
     *   - `root`: Top UL/OL menu template.
59
     *   - `parent`: Wrapper which holds children of a parent node.
60
     *   - `child`: Template for child nodes (leafs).
61
     *   - `link`: Template for link elements.
62
     *
63
     * ## Example:
64
     *
65
     * This example shows where each template is used when rendering a menu.
66
     *
67
     * ```html
68
     * <div> // div template (only if split > 1)
69
     *     <ul> // root template (first part of split menu)
70
     *         <li> // child template
71
     *             <a href="">Link 1</a> // link template
72
     *         </li>
73
     *         <li> // child template
74
     *             <a href="">Link 2</a> // link template
75
     *             <ul> // parent template
76
     *                 <li> // child template
77
     *                     <a href="">Link 2.1</a> // link template
78
     *                 </li>
79
     *                 <li> // child template
80
     *                     <a href="">Link 2.2</a> // link template
81
     *                 </li>
82
     *                 ...
83
     *             </ul>
84
     *         </li>
85
     *         ...
86
     *     </ul>
87
     *
88
     *     <ul> // root template (second part of split menu)
89
     *         ...
90
     *     </ul>
91
     *
92
     *     ...
93
     * </div>
94
     * ```
95
     *
96
     * @var array
97
     */
98
    protected $_defaultConfig = [
99
        'formatter' => null,
100
        'beautify' => false,
101
        'dropdown' => false,
102
        'activeClass' => 'active',
103
        'firstClass' => 'first-item',
104
        'lastClass' => 'last-item',
105
        'hasChildrenClass' => 'has-children',
106
        'split' => false,
107
        'breadcrumbGuessing' => true,
108
        'templates' => [
109
            'div' => '<div{{attrs}}>{{content}}</div>',
110
            'root' => '<ul{{attrs}}>{{content}}</ul>',
111
            'parent' => '<ul{{attrs}}>{{content}}</ul>',
112
            'child' => '<li{{attrs}}>{{content}}{{children}}</li>',
113
            'link' => '<a href="{{url}}"{{attrs}}><span>{{content}}</span></a>',
114
        ]
115
    ];
116
117
    /**
118
     * Flags that indicates this helper is already rendering a menu.
119
     *
120
     * Used to detect loops when using callable formatters.
121
     *
122
     * @var bool
123
     */
124
    protected $_rendering = false;
125
126
    /**
127
     * Other helpers used by this helper.
128
     *
129
     * @var array
130
     */
131
    public $helpers = ['Menu.Link'];
132
133
    /**
134
     * Constructor.
135
     *
136
     * @param View $view The View this helper is being attached to
137
     * @param array $config Configuration settings for the helper, will have no
138
     *  effect as configuration is set on every `render()` call
139
     */
140
    public function __construct(View $view, array $config = [])
141
    {
142
        $this->_defaultConfig['formatter'] = function ($entity, $info) {
143
            return $this->formatter($entity, $info);
144
        };
145
        parent::__construct($view, $config);
146
    }
147
148
    /**
149
     * Renders a nested menu.
150
     *
151
     * This methods renders a HTML menu using a `threaded` result set:
152
     *
153
     * ```php
154
     * // In controller:
155
     * $this->set('links', $this->Links->find('threaded'));
156
     *
157
     * // In view:
158
     * echo $this->Menu->render('links');
159
     * ```
160
     *
161
     * ### Options:
162
     *
163
     * You can pass an associative array `key => value`. Any `key` not in
164
     * `$_defaultConfig` will be treated as an additional attribute for the top
165
     * level UL (root). If `key` is in `$_defaultConfig` it will temporally
166
     * overwrite default configuration parameters, it will be restored to its
167
     * default values after rendering completes:
168
     *
169
     * - `formatter`: Callable method used when formating each item.
170
     * - `activeClass`: CSS class to use when an item is active (its URL matches current URL).
171
     * - `firstItemClass`: CSS class for the first item.
172
     * - `lastItemClass`: CSS class for the last item.
173
     * - `hasChildrenClass`: CSS class to use when an item has children.
174
     * - `split`: Split menu into multiple root menus (multiple UL's)
175
     * - `templates`: The templates you want to use for this menu. Any templates
176
     *    will be merged on top of the already loaded templates. This option can
177
     *    either be a filename in App/config that contains the templates you want
178
     *    to load, or an array of templates to use.
179
     *
180
     * You can also pass a callable function as second argument which will be
181
     * used as formatter:
182
     *
183
     * ```php
184
     * echo $this->Menu->render($links, function ($link, $info) {
185
     *     // render $item here
186
     * });
187
     * ```
188
     *
189
     * Formatters receives two arguments, the item being rendered as first argument
190
     * and information abut the item (has children, depth, etc) as second.
191
     *
192
     * You can pass the ID or slug of a menu as fist argument to render that menu's
193
     * links:
194
     *
195
     * ```php
196
     * echo $this->Menu->render('management');
197
     *
198
     * // OR
199
     *
200
     * echo $this->Menu->render(1);
201
     * ```
202
     *
203
     * @param int|string|array|\Cake\Collection\Collection $items Nested items
204
     *  to render, given as a query result set or as an array list. Or an integer as
205
     *  menu ID in DB to render, or a string as menu Slug in DB to render.
206
     * @param callable|array $config An array of HTML attributes and options as
207
     *  described above or a callable function to use as `formatter`
208
     * @return string HTML
209
     * @throws \Cake\Error\FatalErrorException When loop invocation is detected,
210
     *  that is, when "render()" method is invoked within a callable method when
211
     *  rendering menus.
212
     */
213
    public function render($items, $config = [])
214
    {
215
        if ($this->_rendering) {
216
            throw new FatalErrorException(__d('menu', 'Loop detected, MenuHelper already rendering.'));
217
        }
218
219
        $items = $this->_prepareItems($items);
220
        if (empty($items)) {
221
            return '';
222
        }
223
224
        list($config, $attrs) = $this->_prepareOptions($config);
225
        $this->_rendering = true;
226
        $this->countItems($items);
227
        $this->config($config);
228
229
        if ($this->config('breadcrumbGuessing')) {
230
            $this->Link->config(['breadcrumbGuessing' => $this->config('breadcrumbGuessing')]);
231
        }
232
233
        $out = '';
234
        if (intval($this->config('split')) > 1) {
235
            $out .= $this->_renderPart($items, $config, $attrs);
236
        } else {
237
            $out .= $this->formatTemplate('root', [
238
                'attrs' => $this->templater()->formatAttributes($attrs),
239
                'content' => $this->_render($items)
240
            ]);
241
        }
242
243
        if ($this->config('beautify')) {
244
            include_once Plugin::classPath('Menu') . 'Lib/htmLawed.php';
245
            $tidy = is_bool($this->config('beautify')) ? '1t0n' : $this->config('beautify');
246
            $out = htmLawed($out, compact('tidy'));
247
        }
248
249
        $this->_clear();
250
251
        return $out;
252
    }
253
254
    /**
255
     * Default callable method (see formatter option).
256
     *
257
     * ### Valid options are:
258
     *
259
     * - `templates`: Array of templates indexed as `templateName` => `templatePattern`.
260
     *    Temporally overwrites templates when rendering this item, after item is rendered
261
     *    templates are restored to previous values.
262
     * - `childAttrs`: Array of attributes for `child` template.
263
     *   - `class`: Array list of multiple CSS classes or a single string (will be merged
264
     *      with auto-generated CSS; "active", "has-children", etc).
265
     * - `linkAttrs`: Array of attributes for the `link` template.
266
     *   - `class`: Same as childAttrs.
267
     *
268
     * ### Information argument
269
     *
270
     * The second argument `$info` holds a series of useful values when rendering
271
     * each item of the menu. This values are stored as `key` => `value` array.
272
     *
273
     * - `index` (integer): Position of current item.
274
     * - `total` (integer): Total number of items in the menu being rendered.
275
     * - `depth` (integer): Item depth within the tree structure.
276
     * - `hasChildren` (boolean): true|false
277
     * - `children` (string): HTML content of rendered children for this item.
278
     *    Empty if has no children.
279
     *
280
     * @param \Cake\ORM\Entity $item The item to render
281
     * @param array $info Array of useful information such as described above
282
     * @param array $options Additional options
283
     * @return string
284
     */
285
    public function formatter($item, array $info, array $options = [])
286
    {
287
        if (!empty($options['templates'])) {
288
            $templatesBefore = $this->templates();
289
            $this->templates((array)$options['templates']);
290
            unset($options['templates']);
291
        }
292
293
        $attrs = $this->_prepareItemAttrs($item, $info, $options);
294
        $return = $this->formatTemplate('child', [
295
            'attrs' => $this->templater()->formatAttributes($attrs['child']),
296
            'content' => $this->formatTemplate('link', [
297
                'url' => $this->Link->url($item->url),
298
                'attrs' => $this->templater()->formatAttributes($attrs['link']),
299
                'content' => $item->title,
300
            ]),
301
            'children' => $info['children'],
302
        ]);
303
304
        if (isset($templatesBefore)) {
305
            $this->templates($templatesBefore);
306
        }
307
308
        return $return;
309
    }
310
311
    /**
312
     * Counts items in menu.
313
     *
314
     * @param \Cake\ORM\Query $items Items to count
315
     * @return int
316
     */
317
    public function countItems($items)
318
    {
319
        if ($this->_count) {
320
            return $this->_count;
321
        }
322
        $this->_count($items);
323
324
        return $this->_count;
325
    }
326
327
    /**
328
     * Restores the default template values built into MenuHelper.
329
     *
330
     * @return void
331
     */
332
    public function resetTemplates()
333
    {
334
        $this->templates($this->_defaultConfig['templates']);
335
    }
336
337
    /**
338
     * Prepares options given to "render()" method.
339
     *
340
     * ### Usage:
341
     *
342
     * ```php
343
     * list($options, $attrs) = $this->_prepareOptions($options);
344
     * ```
345
     *
346
     * @param array|callable $options Options given to `render()`
347
     * @return array Array with two keys: `0 => $options` sanitized and filtered
348
     *  options array, and `1 => $attrs` list of attributes for top level UL element
349
     */
350
    protected function _prepareOptions($options = [])
351
    {
352
        $attrs = [];
353
        if (is_callable($options)) {
354
            $this->config('formatter', $options);
355
            $options = [];
356
        } else {
357
            if (!empty($options['templates']) && is_array($options['templates'])) {
358
                $this->templates($options['templates']);
359
                unset($options['templates']);
360
            }
361
362
            foreach ($options as $key => $value) {
363
                if (isset($this->_defaultConfig[$key])) {
364
                    $this->config($key, $value);
365
                } else {
366
                    $attrs[$key] = $value;
367
                }
368
            }
369
        }
370
371
        return [
372
            $options,
373
            $attrs,
374
        ];
375
    }
376
377
    /**
378
     * Prepares item's attributes for rendering.
379
     *
380
     * @param \Cake\Datasource\EntityInterface $item The item being rendered
381
     * @param array $info Item's rendering info
382
     * @param array $options Item's rendering options
383
     * @return array Associative array with two keys, `link` and `child`
384
     * @see \Menu\View\Helper\MenuHelper::formatter()
385
     */
386
    protected function _prepareItemAttrs($item, array $info, array $options)
387
    {
388
        $options = Hash::merge($options, [
389
            'childAttrs' => ['class' => []],
390
            'linkAttrs' => ['class' => []],
391
        ]);
392
        $childAttrs = $options['childAttrs'];
393
        $linkAttrs = $options['linkAttrs'];
394
395
        if (is_string($childAttrs['class'])) {
396
            $childAttrs['class'] = [$childAttrs['class']];
397
        }
398
399
        if (is_string($linkAttrs['class'])) {
400
            $linkAttrs['class'] = [$linkAttrs['class']];
401
        }
402
403
        if ($info['index'] === 1) {
404
            $childAttrs['class'][] = $this->config('firstClass');
405
        }
406
407
        if ($info['index'] === $info['total']) {
408
            $childAttrs['class'][] = $this->config('lastClass');
409
        }
410
411
        if ($info['hasChildren']) {
412
            $childAttrs['class'][] = $this->config('hasChildrenClass');
413
            if ($this->config('dropdown')) {
414
                $childAttrs['class'][] = 'dropdown';
415
                $linkAttrs['data-toggle'] = 'dropdown';
416
            }
417
        }
418
419
        if (!empty($item->description)) {
420
            $linkAttrs['title'] = $item->description;
421
        }
422
423
        if (!empty($item->target)) {
424
            $linkAttrs['target'] = $item->target;
425
        }
426
427
        if ($info['active']) {
428
            $childAttrs['class'][] = $this->config('activeClass');
429
            $linkAttrs['class'][] = $this->config('activeClass');
430
        }
431
432
        $id = $this->_calculateItemId($item);
433
        if (!empty($id)) {
434
            $childAttrs['class'][] = "menu-link-{$id}";
435
        }
436
437
        $childAttrs['class'] = array_unique($childAttrs['class']);
438
        $linkAttrs['class'] = array_unique($linkAttrs['class']);
439
440
        return [
441
            'link' => $linkAttrs,
442
            'child' => $childAttrs,
443
        ];
444
    }
445
446
    /**
447
     * Calculates an item's ID
448
     *
449
     * @param \Cake\Datasource\EntityInterface $item The item
450
     * @return string The ID, it may be an empty
451
     */
452
    protected function _calculateItemId(EntityInterface $item)
453
    {
454
        if ($item->has('id')) {
455
            return $item->id;
456
        }
457
458
        if (is_array($item->url)) {
459
            return Inflector::slug(strtolower(implode(' ', array_values($item->url))));
0 ignored issues
show
Deprecated Code introduced by
The method Cake\Utility\Inflector::slug() has been deprecated with message: 3.2.7 Use Text::slug() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
460
        }
461
462
        return Inflector::slug(strtolower($item->url));
0 ignored issues
show
Deprecated Code introduced by
The method Cake\Utility\Inflector::slug() has been deprecated with message: 3.2.7 Use Text::slug() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
463
    }
464
465
    /**
466
     * Prepares the items (links) to be rendered as part of a menu.
467
     *
468
     * @param mixed $items As described on `render()`
469
     * @return mixed Collection of links to be rendered
470
     */
471
    protected function _prepareItems($items)
472
    {
473
        if (is_integer($items)) {
474
            $id = $items;
475
            $cacheKey = "render({$id})";
476
            $items = static::cache($cacheKey);
477
478
            if ($items === null) {
479
                $items = TableRegistry::get('Menu.MenuLinks')
480
                    ->find('threaded')
481
                    ->where(['menu_id' => $id])
482
                    ->all();
483
                static::cache($cacheKey, $items);
484
            }
485
        } elseif (is_string($items)) {
486
            $slug = $items;
487
            $cacheKey = "render({$slug})";
488
            $items = static::cache($cacheKey);
489
490
            if ($items === null) {
491
                $items = [];
492
                $menu = TableRegistry::get('Menu.Menus')
493
                    ->find()
494
                    ->select(['id'])
495
                    ->where(['slug' => $slug])
496
                    ->first();
497
498
                if ($menu instanceof EntityInterface) {
499
                    $items = TableRegistry::get('Menu.MenuLinks')
500
                        ->find('threaded')
501
                        ->where(['menu_id' => $menu->id])
502
                        ->all();
503
                }
504
                static::cache($cacheKey, $items);
505
            }
506
        }
507
508
        return $items;
509
    }
510
511
    /**
512
     * Starts rendering process of a menu's parts (when using the "split" option).
513
     *
514
     * @param mixed $items Menu links
515
     * @param array $options Options for the rendering process
516
     * @param array $attrs Menu's attributes
517
     * @return string
518
     */
519
    protected function _renderPart($items, $options, $attrs)
520
    {
521
        if (is_object($items) && method_exists($items, 'toArray')) {
522
            $arrayItems = $items->toArray();
523
        } else {
524
            $arrayItems = (array)$items;
525
        }
526
527
        $chunkOut = '';
528
        $size = round(count($arrayItems) / intval($this->config('split')));
529
        $chunk = array_chunk($arrayItems, $size);
530
        $i = 1;
531
532
        foreach ($chunk as $menu) {
533
            $chunkOut .= $this->formatTemplate('parent', [
534
                'attrs' => $this->templater()->formatAttributes(['class' => "menu-part part-{$i}"]),
535
                'content' => $this->_render($menu, $this->config('formatter'))
536
            ]);
537
            $i++;
538
        }
539
540
        return $this->formatTemplate('div', [
541
            'attrs' => $this->templater()->formatAttributes($attrs),
542
            'content' => $chunkOut,
543
        ]);
544
    }
545
546
    /**
547
     * Internal method to recursively generate the menu.
548
     *
549
     * @param \Cake\ORM\Query $items Items to render
550
     * @param int $depth Current iteration depth
551
     * @return string HTML
552
     */
553
    protected function _render($items, $depth = 0)
554
    {
555
        $content = '';
556
        $formatter = $this->config('formatter');
557
558
        foreach ($items as $item) {
559
            $children = '';
560
            if (is_array($item)) {
561
                $item = new Entity($item);
562
            }
563
564
            if ($item->has('children') && !empty($item->children) && $item->expanded) {
565
                $children = $this->formatTemplate('parent', [
566
                    'attrs' => $this->templater()->formatAttributes([
567
                        'class' => ($this->config('dropdown') ? 'dropdown-menu multi-level' : ''),
568
                        'role' => 'menu'
569
                    ]),
570
                    'content' => $this->_render($item->children, $depth + 1)
571
                ]);
572
            }
573
574
            $this->_index++;
575
            $info = [
576
                'index' => $this->_index,
577
                'total' => $this->_count,
578
                'active' => $this->Link->isActive($item),
579
                'depth' => $depth,
580
                'hasChildren' => !empty($children),
581
                'children' => $children,
582
            ];
583
            $content .= $formatter($item, $info);
584
        }
585
586
        return $content;
587
    }
588
589
    /**
590
     * Internal method for counting items in menu.
591
     *
592
     * This method will ignore children if parent has been marked as `do no expand`.
593
     *
594
     * @param \Cake\ORM\Query $items Items to count
595
     * @return int
596
     */
597
    protected function _count($items)
598
    {
599
        foreach ($items as $item) {
600
            $this->_count++;
601
            $item = is_array($item) ? new Entity($item) : $item;
602
603
            if ($item->has('children') && !empty($item->children) && $item->expanded) {
604
                $this->_count($item->children);
605
            }
606
        }
607
    }
608
609
    /**
610
     * Clears all temporary variables used when rendering a menu, so they do not
611
     * interfere when rendering other menus.
612
     *
613
     * @return void
614
     */
615
    protected function _clear()
616
    {
617
        $this->_index = 0;
618
        $this->_count = 0;
619
        $this->_rendering = false;
620
        $this->config($this->_defaultConfig);
621
        $this->Link->config(['breadcrumbGuessing' => $this->_defaultConfig['breadcrumbGuessing']]);
622
        $this->resetTemplates();
623
    }
624
}
625