Passed
Pull Request — master (#82)
by Wilmer
02:37
created

Nav   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 393
Duplicated Lines 0 %

Test Coverage

Coverage 99.2%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 123
dl 0
loc 393
ccs 124
cts 125
cp 0.992
rs 8.96
c 2
b 0
f 0
wmc 43

18 Methods

Rating   Name   Duplication   Size   Complexity  
A withoutEncodeLabels() 0 6 1
A withoutActivateItems() 0 6 1
A items() 0 6 1
A activateParents() 0 6 1
A options() 0 6 1
A dropdownOptions() 0 6 1
A homeLink() 0 6 1
A isItemActive() 0 10 5
A dropdownClass() 0 6 1
A run() 0 9 2
A renderDropdown() 0 13 2
A currentPath() 0 6 1
A renderItems() 0 15 4
A linkOptions() 0 6 1
A activeClass() 0 10 2
B renderItem() 0 49 9
B isChildActive() 0 23 8
A itemOptions() 0 6 1

How to fix   Complexity   

Complex Class

Complex classes like Nav often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Nav, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Bootstrap5;
6
7
use JsonException;
8
use RuntimeException;
9
use Yiisoft\Arrays\ArrayHelper;
10
use Yiisoft\Html\Html;
11
12
use function is_array;
13
use function is_string;
14
15
/**
16
 * Nav renders a nav HTML component.
17
 *
18
 * For example:
19
 *
20
 * ```php
21
 *    if ($user->getId() !== null) {
22
 *        $menuItems = [
23
 *            [
24
 *                'label' => 'About',
25
 *                'url' => '/about',
26
 *            ],
27
 *            [
28
 *                'label' => 'Contact',
29
 *                'url' => '/contact',
30
 *            ],
31
 *            [
32
 *                'label' => 'Logout' . ' ' . '(' . $user->getUsername() . ')',
33
 *                'url' => '/logout'
34
 *            ],
35
 *        ];
36
 *    } else {
37
 *        $menuItems = [
38
 *            [
39
 *                'label' => 'About',
40
 *                'url' => '/about',
41
 *            ],
42
 *            [
43
 *                'label' => 'Contact',
44
 *                'url' => '/contact',
45
 *            ],
46
 *            [
47
 *                'label' => 'Login',
48
 *                'url' => '/login',
49
 *            ],
50
 *        ];
51
 *    }
52
 *
53
 *    echo Nav::widget()
54
 *        ->currentPath($currentPath)
55
 *        ->items($menuItems)
56
 *        ->options([
57
 *            'class' => 'navbar-nav float-right ml-auto'
58
 *        ]);
59
 *
60
 * Note: Multilevel dropdowns beyond Level 1 are not supported in Bootstrap 3.
61
 * Note: $currentPath it must be injected from each controller to the main controller.
62
 *
63
 * SiteController.php
64
 *
65
 * ```php
66
 *
67
 *    public function index(ServerRequestInterface $request): ResponseInterface
68
 *    {
69
 *        $response = $this->responseFactory->createResponse();
70
 *        $currentPath = $request->getUri()->getPath();
71
 *        $output = $this->render('index', ['currentPath' => $currentPath]);
72
 *        $response->getBody()->write($output);
73
 *
74
 *        return $response;
75
 *    }
76
 * ```
77
 *
78
 * Controller.php
79
 *
80
 * ```php
81
 *    private function renderContent($content, array $parameters = []): string
82
 *    {
83
 *        $user = $this->user->getIdentity();
84
 *        $layout = $this->findLayoutFile($this->layout);
85
 *
86
 *        if ($layout !== null) {
87
 *            return $this->view->renderFile(
88
 *                $layout,
89
 *                    [
90
 *                        'aliases' => $this->aliases,
91
 *                        'content' => $content,
92
 *                        'user' => $user,
93
 *                        'params' => $this->params,
94
 *                        'currentPath' => !isset($parameters['currentPath']) ?: $parameters['currentPath']
95
 *                    ],
96
 *                $this
97
 *            );
98
 *        }
99
 *
100
 *        return $content;
101
 *    }
102
 * ```
103
 *
104
 * {@see http://getbootstrap.com/components/#dropdowns}
105
 * {@see http://getbootstrap.com/components/#nav}
106
 */
107
final class Nav extends Widget
108
{
109
    private array $items = [];
110
    private bool $encodeLabels = true;
111
    private bool $encodeTags = false;
112
    private bool $activateItems = true;
113
    private bool $activateParents = false;
114
    private ?string $activeClass = null;
115
    private string $currentPath = '';
116
    private string $dropdownClass = Dropdown::class;
117
    private array $options = [];
118
    private array $itemOptions = [];
119
    private array $linkOptions = [];
120
    private array $dropdownOptions = [];
121
    private string $homeLink = '/';
122
123 36
    protected function run(): string
124
    {
125 36
        if (!isset($this->options['id'])) {
126 19
            $this->options['id'] = "{$this->getId()}-nav";
127
        }
128
129 36
        Html::addCssClass($this->options, ['widget' => 'nav']);
130
131 36
        return $this->renderItems();
132
    }
133
134
    /**
135
     * List of items in the nav widget. Each array element represents a single  menu item which can be either a string
136
     * or an array with the following structure:
137
     *
138
     * - label: string, required, the nav item label.
139
     * - url: optional, the item's URL. Defaults to "#".
140
     * - visible: bool, optional, whether this menu item is visible. Defaults to true.
141
     * - linkOptions: array, optional, the HTML attributes of the item's link.
142
     * - options: array, optional, the HTML attributes of the item container (LI).
143
     * - active: bool, optional, whether the item should be on active state or not.
144
     * - dropdownOptions: array, optional, the HTML options that will passed to the {@see Dropdown} widget.
145
     * - items: array|string, optional, the configuration array for creating a {@see Dropdown} widget, or a string
146
     *   representing the dropdown menu. Note that Bootstrap does not support sub-dropdown menus.
147
     * - encode: bool, optional, whether the label will be HTML-encoded. If set, supersedes the $encodeLabels option for
148
     *   only this item.
149
     *
150
     * If a menu item is a string, it will be rendered directly without HTML encoding.
151
     *
152
     * @param array $value
153
     *
154
     * @return self
155
     */
156 36
    public function items(array $value): self
157
    {
158 36
        $new = clone $this;
159 36
        $new->items = $value;
160
161 36
        return $new;
162
    }
163
164
    /**
165
     * When tags Labels HTML should not be encoded.
166
     *
167
     * @return self
168
     */
169 4
    public function withoutEncodeLabels(): self
170
    {
171 4
        $new = clone $this;
172 4
        $new->encodeLabels = false;
173
174 4
        return $new;
175
    }
176
177
    /**
178
     * Disable activate items according to whether their currentPath.
179
     *
180
     * @return self
181
     *
182
     * {@see isItemActive}
183
     */
184 2
    public function withoutActivateItems(): self
185
    {
186 2
        $new = clone $this;
187 2
        $new->activateItems = false;
188
189 2
        return $new;
190
    }
191
192
    /**
193
     * Whether to activate parent menu items when one of the corresponding child menu items is active.
194
     *
195
     * @return self
196
     */
197 1
    public function activateParents(): self
198
    {
199 1
        $new = clone $this;
200 1
        $new->activateParents = true;
201
202 1
        return $new;
203
    }
204
205
    /**
206
     * Additional CSS class for active item. Like "bg-success", "bg-primary" etc
207
     *
208
     * @param string|null $className
209
     *
210
     * @return self
211
     */
212 1
    public function activeClass(?string $className): self
213
    {
214 1
        if ($this->activeClass === $className) {
215
            return $this;
216
        }
217
218 1
        $new = clone $this;
219 1
        $new->activeClass = $className;
220
221 1
        return $new;
222
    }
223
224
    /**
225
     * Allows you to assign the current path of the url from request controller.
226
     *
227
     * @param string $value
228
     *
229
     * @return self
230
     */
231 3
    public function currentPath(string $value): self
232
    {
233 3
        $new = clone $this;
234 3
        $new->currentPath = $value;
235
236 3
        return $new;
237
    }
238
239
    /**
240
     * Name of a class to use for rendering dropdowns within this widget. Defaults to {@see Dropdown}.
241
     *
242
     * @param string $value
243
     *
244
     * @return self
245
     */
246 2
    public function dropdownClass(string $value): self
247
    {
248 2
        $new = clone $this;
249 2
        $new->dropdownClass = $value;
250
251 2
        return $new;
252
    }
253
254
    /**
255
     * Options for dropdownClass if not present in current item
256
     *
257
     * {@see Nav::renderDropdown()} for details on how this options will be used
258
     *
259
     * @param array $options
260
     *
261
     * @return self
262
     */
263 1
    public function dropdownOptions(array $options): self
264
    {
265 1
        $new = clone $this;
266 1
        $new->dropdownOptions = $options;
267
268 1
        return $new;
269
    }
270
271
    /**
272
     * The HTML attributes for the widget container tag. The following special options are recognized.
273
     *
274
     * {@see Html::renderTagAttributes()} for details on how attributes are being rendered.
275
     *
276
     * @param array $value
277
     *
278
     * @return self
279
     */
280 19
    public function options(array $value): self
281
    {
282 19
        $new = clone $this;
283 19
        $new->options = $value;
284
285 19
        return $new;
286
    }
287
288
    /**
289
     * Options for each item if not present in self
290
     *
291
     * @param array $options
292
     *
293
     * @return self
294
     */
295 4
    public function itemOptions(array $options): self
296
    {
297 4
        $new = clone $this;
298 4
        $new->itemOptions = $options;
299
300 4
        return $new;
301
    }
302
303
    /**
304
     * Options for each item link if not present in current item
305
     *
306
     * @param array $options
307
     *
308
     * @return self
309
     */
310 3
    public function linkOptions(array $options): self
311
    {
312 3
        $new = clone $this;
313 3
        $new->linkOptions = $options;
314
315 3
        return $new;
316
    }
317
318
    /**
319
     * Return new instance of {@see Nav} with home link.
320
     *
321
     * @param string $value The home link
322
     *
323
     * @return self
324
     */
325 1
    public function homeLink(string $value): self
326
    {
327 1
        $new = clone $this;
328 1
        $new->homeLink = $value;
329
330 1
        return $new;
331
    }
332
333
    /**
334
     * Renders widget items.
335
     *
336
     * @throws JsonException|RuntimeException
337
     *
338
     * @return string
339
     */
340 36
    private function renderItems(): string
341
    {
342 36
        $items = [];
343
344 36
        foreach ($this->items as $i => $item) {
345 35
            if (isset($item['visible']) && !$item['visible']) {
346 5
                continue;
347
            }
348
349 35
            $items[] = $this->renderItem($item);
350
        }
351
352 35
        return Html::tag('ul', implode("\n", $items), $this->options)
353 35
            ->encode($this->encodeTags)
354 35
            ->render();
355
    }
356
357
    /**
358
     * Renders a widget's item.
359
     *
360
     * @param array|string $item the item to render.
361
     *
362
     * @throws JsonException|RuntimeException
363
     *
364
     * @return string the rendering result.
365
     */
366 35
    private function renderItem($item): string
367
    {
368 35
        if (is_string($item)) {
369 2
            return $item;
370
        }
371
372 35
        if (!isset($item['label'])) {
373 1
            throw new RuntimeException("The 'label' option is required.");
374
        }
375
376 34
        $encodeLabel = $item['encode'] ?? $this->encodeLabels;
377 34
        $label = $encodeLabel ? Html::encode($item['label']) : $item['label'];
378 34
        $options = ArrayHelper::getValue($item, 'options', $this->itemOptions);
379 34
        $items = ArrayHelper::getValue($item, 'items');
380 34
        $url = ArrayHelper::getValue($item, 'url', '#');
381 34
        $linkOptions = ArrayHelper::getValue($item, 'linkOptions', $this->linkOptions);
382 34
        $disabled = ArrayHelper::getValue($item, 'disabled', false);
383 34
        $active = $this->isItemActive($item);
384
385 34
        if (empty($items)) {
386 28
            $items = '';
387
        } else {
388 15
            $linkOptions['data-bs-toggle'] = 'dropdown';
389
390 15
            Html::addCssClass($options, ['widget' => 'dropdown']);
391 15
            Html::addCssClass($linkOptions, ['widget' => 'dropdown-toggle']);
392
393 15
            if (is_array($items)) {
394 15
                $items = $this->isChildActive($items, $active);
395 15
                $items = $this->renderDropdown($items, $item);
396
            }
397
        }
398
399 34
        Html::addCssClass($options, ['nav' => 'nav-item']);
400 34
        Html::addCssClass($linkOptions, ['linkOptions' => 'nav-link']);
401
402 34
        if ($disabled) {
403 3
            $linkOptions['tabindex'] = '-1';
404 3
            $linkOptions['aria-disabled'] = 'true';
405 3
            Html::addCssClass($linkOptions, ['disabled' => 'disabled']);
406 34
        } elseif ($this->activateItems && $active) {
407 20
            Html::addCssClass($linkOptions, ['active' => rtrim('active ' . $this->activeClass)]);
408
        }
409
410 34
        return Html::tag(
411
            'li',
412 34
            Html::a($label, $url, $linkOptions)->encode($this->encodeTags) . $items,
413
            $options
414 34
        )->encode($this->encodeTags)->render();
415
    }
416
417
    /**
418
     * Renders the given items as a dropdown.
419
     *
420
     * This method is called to create sub-menus.
421
     *
422
     * @param array $items the given items. Please refer to {@see Dropdown::items} for the array structure.
423
     * @param array $parentItem the parent item information. Please refer to {@see items} for the structure of this
424
     * array.
425
     *
426
     * @return string the rendering result.
427
     */
428 15
    private function renderDropdown(array $items, array $parentItem): string
429
    {
430 15
        $dropdownClass = $this->dropdownClass;
431
432 15
        $dropdown = $dropdownClass::widget()
433 15
            ->items($items)
434 15
            ->options(ArrayHelper::getValue($parentItem, 'dropdownOptions', $this->dropdownOptions));
435
436 15
        if ($this->encodeLabels === false) {
437 1
            $dropdown->withoutEncodeLabels();
438
        }
439
440 15
        return $dropdown->render();
441
    }
442
443
    /**
444
     * Check to see if a child item is active optionally activating the parent.
445
     *
446
     * @param array $items
447
     * @param bool $active should the parent be active too
448
     *
449
     * @return array
450
     *
451
     * {@see items}
452
     */
453 15
    private function isChildActive(array $items, bool &$active): array
454
    {
455 15
        foreach ($items as $i => $child) {
456 15
            if ($this->isItemActive($child)) {
457 3
                ArrayHelper::setValue($items[$i], 'active', true);
458 3
                if ($this->activateParents) {
459 1
                    $active = true;
460
                }
461
            }
462
463 15
            if (is_array($child) && ($childItems = ArrayHelper::getValue($child, 'items')) && is_array($childItems)) {
464 1
                $activeParent = false;
465 1
                $items[$i]['items'] = $this->isChildActive($childItems, $activeParent);
466
467 1
                if ($activeParent) {
468 1
                    $items[$i]['linkOptions'] ??= [];
469 1
                    Html::addCssClass($items[$i]['linkOptions'], ['active' => 'active']);
470 1
                    $active = true;
471
                }
472
            }
473
        }
474
475 15
        return $items;
476
    }
477
478
    /**
479
     * Checks whether a menu item is active.
480
     *
481
     * This is done by checking if {@see currentPath} match that specified in the `url` option of the menu item. When
482
     * the `url` option of a menu item is specified in terms of an array, its first element is treated as the
483
     * currentPath for the item and the rest of the elements are the associated parameters. Only when its currentPath
484
     * and parameters match {@see currentPath}, respectively, will a menu item be considered active.
485
     *
486
     * @param array|string $item the menu item to be checked
487
     *
488
     * @return bool whether the menu item is active
489
     */
490 34
    private function isItemActive($item): bool
491
    {
492 34
        if (isset($item['active'])) {
493 22
            return ArrayHelper::getValue($item, 'active', false);
0 ignored issues
show
Bug introduced by
It seems like $item can also be of type string; however, parameter $array of Yiisoft\Arrays\ArrayHelper::getValue() does only seem to accept array|object, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

493
            return ArrayHelper::getValue(/** @scrutinizer ignore-type */ $item, 'active', false);
Loading history...
494
        }
495
496 27
        return isset($item['url'])
497 27
            && $this->currentPath !== $this->homeLink
498 27
            && $item['url'] === $this->currentPath
499 27
            && $this->activateItems;
500
    }
501
}
502