Completed
Pull Request — development (#3050)
by John
23:37
created

Menu::setSubsSectionUrl()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 1
nop 4
dl 0
loc 8
ccs 6
cts 6
cp 1
crap 2
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This class contains a standard way of displaying side/drop down menus.
5
 *
6
 * @name      ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause
9
 *
10
 * This file contains code covered by:
11
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
12
 * license:   BSD, See included LICENSE.TXT for terms and conditions.
13
 *
14
 * @version   2.0 dev
15
 *
16
 */
17
18
declare(strict_types=1);
19
20
namespace ElkArte\Menu;
21
22
use HttpReq;
23
24
/**
25
 * This class implements a standard way of creating menus
26
 */
27
class Menu
28
{
29
	/** @var HttpReq */
30
	protected $req;
31
32
	/** @var array Will hold the created $context */
33
	protected $menuContext = [];
34
35
	/** @var string Used for profile menu for own / any */
36
	protected $permissionSet;
37
38
	/** @var bool If we found the menu item selected */
39
	protected $foundSection = false;
40
41
	/** @var string Current area */
42
	protected $currentArea = '';
43
44
	/** @var null|string The current subaction of the system */
45
	protected $currentSubaction = '';
46
47
	/** @var array Will hold the selected menu data that is returned to the caller */
48
	private $includeData = [];
49
50
	/** @var int Unique menu number */
51
	private $maxMenuId = 0;
52
53
	/** @var MenuOptions  Holds menu options */
54
	private $menuOptions;
55
56
	/** @var array  Holds menu definition structure set by addSection */
57
	private $menuData = [];
58
59
	/**
60
	 * Initial processing for the menu
61
	 *
62
	 * @param HttpReq|null $req
63
	 */
64 41
	public function __construct(HttpReq $req = null)
65
	{
66 41
		global $context;
67
68
		// Access to post/get data
69 41
		$this->req = $req ?: HttpReq::instance();
70
71
		// Every menu gets a unique ID, these are shown in first in, first out order.
72 41
		$this->maxMenuId = ($context['max_menu_id'] ?? 0) + 1;
73
74
		// This will be all the data for this menu
75 41
		$this->menuContext = [];
76
77
		// This is necessary only in profile (at least for the core), but we do it always because it's easier
78 41
		$this->permissionSet = !empty($context['user']['is_owner']) ? 'own' : 'any';
79
80
		// We may have a current subaction
81 41
		$this->currentSubaction = $context['current_subaction'] ?? null;
82 41
	}
83
84
	/**
85
	 * Create a menu
86
	 *
87
	 * @return array
88
	 * @throws \Elk_Exception
89
	 */
90 40
	public function prepareMenu(): array
91
	{
92
		 // Build URLs first.
93 40
		$this->menuContext['base_url'] = $this->menuOptions->getBaseUrl();
94 39
		$this->menuContext['current_action'] = $this->menuOptions->getAction();
95 39
		$this->currentArea = $this->req->getQuery('area', 'trim|strval', $this->menuOptions->getCurrentArea());
96 39
		$this->menuContext['extra_parameters'] = $this->menuOptions->buildAdditionalParams();
97
98
		// Process the loopy menu data.
99 39
		$this->processMenuData();
100
101
		// Here is some activity.
102 39
		$this->setActiveButtons();
103
104
		// Make sure we created some awesome sauce.
105 39
		if (empty($this->includeData))
106
		{
107
			// No valid areas -- reject!
108 1
			throw new \Elk_Exception('no_access');
109
		}
110
111
		// Finally - return information on the selected item.
112 38
		return $this->includeData + [
113 38
				'current_action' => $this->menuContext['current_action'],
114 38
				'current_area' => $this->currentArea,
115 38
				'current_section' => !empty($this->menuContext['current_section']) ? $this->menuContext['current_section'] : '',
116 38
				'current_subsection' => $this->currentSubaction,
117
			];
118
	}
119
120
	/**
121
	 * Prepares tabs for the template.
122
	 *
123
	 * This should be called after the area is dispatched, because areas
124
	 * are usually in their own file. Those files, once dispatched to, hold
125
	 * some data for the tabs which must be specially combined with subaction
126
	 * data for everything to work properly.
127
	 *
128
	 * Seems complicated, yes.
129
	 */
130 1
	public function prepareTabData(): void
131
	{
132 1
		global $context;
133
134
		// Handy shortcut.
135 1
		$tabContext = &$context['menu_data_' . $this->maxMenuId]['tab_data'];
136
137
		// Tabs are really just subactions.
138 1
		if (isset($tabContext['tabs'], $this->menuContext['sections'][$this->menuContext['current_section']]['areas'][$this->currentArea]['subsections']))
139
		{
140
			$tabContext['tabs'] = array_replace_recursive(
141
				$tabContext['tabs'],
142
				$this->menuContext['sections'][$this->menuContext['current_section']]['areas'][$this->currentArea]['subsections']
143
			);
144
145
			// Has it been deemed selected?
146
			$tabContext = array_merge($tabContext, $tabContext['tabs'][$this->currentSubaction]);
147
		}
148 1
	}
149
150
	/**
151
	 * Process the menuData array passed to the class
152
	 *
153
	 *   - Only processes areas that are enabled and that the user has permissions
154
	 */
155 39
	protected function processMenuData(): void
156
	{
157
		// Now setup the context correctly.
158 39
		foreach ($this->menuData as $sectionId => $section)
159
		{
160
			// Is this section enabled? and do they have permissions?
161 39
			if ($section->isEnabled() && $this->checkPermissions($section))
162
			{
163 38
				$this->setSectionContext($sectionId, $section);
164
165
				// Process this menu section
166 39
				$this->processSectionAreas($sectionId, $section);
167
			}
168
		}
169 39
	}
170
171
	/**
172
	 * Determines if the user has the permissions to access the section/area
173
	 *
174
	 * If said item did not provide any permission to check, fullly
175
	 * unfettered access is assumed.
176
	 *
177
	 * The profile areas are a bit different in that each permission is
178
	 * divided into two sets: "own" for owner and "any" for everyone else.
179
	 *
180
	 * @param MenuItem $obj area or section being checked
181
	 *
182
	 * @return bool
183
	 */
184 39
	private function checkPermissions(MenuItem $obj): bool
185
	{
186 39
		if (!empty($obj->getPermission()))
187
		{
188
			// The profile menu has slightly different permissions
189 39
			if (isset($obj->getPermission()['own'], $obj->getPermission()['any']))
190
			{
191 1
				return allowedTo($obj->getPermission()[$this->permissionSet]);
192
			}
193
194 39
			return allowedTo($obj->getPermission());
195
		}
196
197 38
		return true;
198
	}
199
200
	/**
201
	 * Checks if the area has a label or not
202
	 *
203
	 * @param string   $areaId
204
	 * @param MenuArea $area
205
	 *
206
	 * @return bool
207
	 */
208 38
	private function areaHasLabel(string $areaId, MenuArea $area): bool
209
	{
210 38
		global $txt;
211
212 38
		return !empty($area->getLabel()) || isset($txt[$areaId]);
213
	}
214
215
	/**
216
	 * Main processing for creating the menu items for all sections
217
	 *
218
	 * @param string      $sectionId
219
	 * @param MenuSection $section
220
	 */
221 38
	protected function processSectionAreas(string $sectionId, MenuSection $section): void
222
	{
223
		// Now we cycle through the sections to pick the right area.
224 38
		foreach ($section->getAreas() as $areaId => $area)
225
		{
226
			// Is the area enabled, Does the user have permission and it has some form of a name
227 38
			if ($area->isEnabled() && $this->checkPermissions($area) && $this->areaHasLabel($areaId, $area))
228
			{
229
				// Make sure we have a valid current area
230 38
				$this->setFirstAreaCurrent($sectionId, $areaId, $area);
231
232
				// If this is hidden from view don't do the rest.
233 38
				if (!$area->isHidden())
234
				{
235
					// First time this section?
236 38
					$this->setAreaContext($sectionId, $areaId, $area);
237
238
					// Maybe a custom url
239 38
					$this->setAreaUrl($sectionId, $areaId, $area);
240
241
					// Even a little icon
242 38
					$this->setAreaIcon($sectionId, $areaId, $area);
243
244
					// Did it have subsections?
245 38
					$this->processAreaSubsections($sectionId, $areaId, $area);
246
				}
247
248
				// Is this the current section?
249 38
				$this->checkCurrentSection($sectionId, $areaId, $area);
250
			}
251
		}
252 38
	}
253
254
	/**
255
	 * Checks the menu item to see if it is the currently selected one
256
	 *
257
	 * @param string   $sectionId
258
	 * @param string   $areaId
259
	 * @param MenuArea $area
260
	 */
261 38
	private function checkCurrentSection(string $sectionId, string $areaId, MenuArea $area): void
262
	{
263
		// Is this the current section?
264 38
		if ($this->currentArea == $areaId && !$this->foundSection)
265
		{
266 38
			$this->setAreaCurrent($sectionId, $areaId, $area);
267
268
			// Only do this once, m'kay?
269 38
			$this->foundSection = true;
270
		}
271 38
	}
272
273
	/**
274
	 * @param string   $sectionId
275
	 * @param string   $areaId
276
	 * @param MenuArea $area
277
	 */
278 38
	private function setFirstAreaCurrent(string $sectionId, string $areaId, MenuArea $area): void
279
	{
280
		// If we don't have an area then the first valid one is our choice.
281 38
		if (empty($this->currentArea))
282
		{
283 38
			$this->setAreaCurrent($sectionId, $areaId, $area);
284
		}
285 38
	}
286
287
	/**
288
	 * Simply sets the current area
289
	 *
290
	 * @param string   $sectionId
291
	 * @param string   $areaId
292
	 * @param MenuArea $area
293
	 */
294 38
	private function setAreaCurrent(string $sectionId, string $areaId, MenuArea $area): void
295
	{
296
		// Update the context if required - as we can have areas pretending to be others. ;)
297 38
		$this->menuContext['current_section'] = $sectionId;
298 38
		$this->currentArea = $area->getSelect() ?: $areaId;
299 38
		$this->includeData = $area->toArray();
300 38
	}
301
302
	/**
303
	 * @param MenuItem $obj
304
	 * @param integer  $idx
305
	 *
306
	 * @return string
307
	 */
308 38
	private function parseCounter(MenuItem $obj, int $idx): string
309
	{
310 38
		global $settings;
311
312 38
		$counter = '';
313 38
		if (!empty($this->menuOptions->getCounters()[$obj->getCounter()]))
314
		{
315 2
			$counter = sprintf(
316 2
				$settings['menu_numeric_notice'][$idx],
317 2
				$this->menuOptions->getCounters()[$obj->getCounter()]
318
			);
319
		}
320
321 38
		return $counter;
322
	}
323
324
	/**
325
	 * Sets the various section ID items
326
	 *
327
	 * What it does:
328
	 *   - If the ID is not set, sets it and sets the section title
329
	 *   - Sets the section title
330
	 *
331
	 * @param string      $sectionId
332
	 * @param MenuSection $section
333
	 */
334 38
	private function setSectionContext(string $sectionId, MenuSection $section): void
335
	{
336 38
		global $txt;
337
338 38
		$this->menuContext['sections'][$sectionId] = [
339 38
			'id' => $sectionId,
340 38
			'label' => ($section->getLabel() ?: $txt[$sectionId]) . $this->parseCounter($section, 0),
341 38
			'url' => $this->menuContext['base_url'] . $this->menuContext['extra_parameters'],
342
		];
343 38
	}
344
345
	/**
346
	 * Sets the various area items
347
	 *
348
	 * What it does:
349
	 *   - If the ID is not set, sets it and sets the area title
350
	 *   - Sets the area title
351
	 *
352
	 * @param string   $sectionId
353
	 * @param string   $areaId
354
	 * @param MenuArea $area
355
	 */
356 38
	private function setAreaContext(string $sectionId, string $areaId, MenuArea $area): void
357
	{
358 38
		global $txt;
359
360 38
		$this->menuContext['sections'][$sectionId]['areas'][$areaId] = [
361 38
			'label' => ($area->getLabel() ?: $txt[$areaId]) . $this->parseCounter($area, 1),
362
		];
363 38
	}
364
365
	/**
366
	 * Set the URL for the menu item
367
	 *
368
	 * @param string   $sectionId
369
	 * @param string   $areaId
370
	 * @param MenuArea $area
371
	 */
372 38
	private function setAreaUrl(string $sectionId, string $areaId, MenuArea $area): void
373
	{
374 38
		$area->setUrl(
375 38
			$this->menuContext['sections'][$sectionId]['areas'][$areaId]['url'] =
376 38
				$area->getUrl(
377 38
				) ?: $this->menuContext['base_url'] . ';area=' . $areaId . $this->menuContext['extra_parameters']
378
		);
379 38
	}
380
381
	/**
382
	 * Set the menu icon
383
	 *
384
	 * @param string   $sectionId
385
	 * @param string   $areaId
386
	 * @param MenuArea $area
387
	 */
388 38
	private function setAreaIcon(string $sectionId, string $areaId, MenuArea $area): void
389
	{
390 38
		global $settings;
391
392
		// Work out where we should get our menu images from.
393 38
		$imagePath = file_exists($settings['theme_dir'] . '/images/admin/change_menu.png')
394 38
			? $settings['images_url'] . '/admin'
395 38
			: $settings['default_images_url'] . '/admin';
396
397
		// Does this area have its own icon?
398 38
		if (!empty($area->getIcon()))
399
		{
400 1
			$this->menuContext['sections'][$sectionId]['areas'][$areaId]['icon'] =
401 1
				'<img ' . (!empty($area->getClass()) ? 'class="' . $area->getClass(
402 1
					) . '" ' : 'style="background: none"') . ' src="' . $imagePath . '/' . $area->getIcon(
403 1
				) . '" alt="" />&nbsp;&nbsp;';
404
		}
405
		else
406
		{
407 38
			$this->menuContext['sections'][$sectionId]['areas'][$areaId]['icon'] = '';
408
		}
409 38
	}
410
411
	/**
412
	 * Processes all of the subsections for a menu item
413
	 *
414
	 * @param string   $sectionId
415
	 * @param string   $areaId
416
	 * @param MenuArea $area
417
	 */
418 38
	protected function processAreaSubsections(string $sectionId, string $areaId, MenuArea $area): void
419
	{
420 38
			$this->menuContext['sections'][$sectionId]['areas'][$areaId]['subsections'] = [];
421
422
			// For each subsection process the options
423 38
			$subSections = array_filter(
424 38
				$area->getSubsections(),
425 38
				function ($sub) {
426 38
					return $this->checkPermissions($sub) && $sub->isEnabled();
427 38
				}
428
			);
429 38
			foreach ($subSections as $subId => $sub)
430
			{
431 38
				$this->menuContext['sections'][$sectionId]['areas'][$areaId]['subsections'][$subId] = [
432 38
					'label' => $sub->getLabel() . $this->parseCounter($sub, 2),
433
				];
434
435 38
				$this->setSubsSectionUrl($sectionId, $areaId, $subId, $sub);
436
437 38
				if ($this->currentArea == $areaId)
438
				{
439 38
					$this->setCurrentSubSection($subId, $sub);
440
				}
441
			}
442 38
			$this->setDefaultSubSection($areaId, $subSections);
443 38
	}
444
445
	/**
446
	 * @param string         $sectionId
447
	 * @param string         $areaId
448
	 * @param string         $subId
449
	 * @param MenuSubsection $sub
450
	 */
451 38
	private function setSubsSectionUrl(string $sectionId, string $areaId, string $subId, MenuSubsection $sub): void
452
	{
453 38
		$sub->setUrl(
454 38
			$this->menuContext['sections'][$sectionId]['areas'][$areaId]['subsections'][$subId]['url'] =
455 38
				$sub->getUrl(
456 38
				) ?: $this->menuContext['base_url'] . ';area=' . $areaId . ';sa=' . $subId . $this->menuContext['extra_parameters']
457
		);
458 38
	}
459
460
	/**
461
	 * Set the current subsection
462
	 *
463
	 * @param string         $subId
464
	 * @param MenuSubsection $sub
465
	 */
466
	private function setCurrentSubSection(string $subId, MenuSubsection $sub): void
467
	{
468
		// Is this the current subsection?
469
		$subIdCheck = $this->req->getQuery('sa', 'trim', null);
470
		if (
471
			$subIdCheck == $subId
472
			|| in_array($subIdCheck, $sub->getActive(), true)
473
			|| empty($this->currentSubaction) && $sub->isDefault()
474
		)
475
		{
476
			$this->currentSubaction = $subId;
477
		}
478
	}
479
480
	/**
481
	 * Ensures that the current subsection is set.
482
	 *
483
	 * @param string $areaId
484
	 * @param array  $subSections
485
	 */
486 38
	private function setDefaultSubSection(string $areaId, array $subSections): void
487
	{
488 38
		if ($this->currentArea == $areaId && empty($this->currentSubaction))
489
		{
490 37
			$this->currentSubaction = key($subSections) ?? '';
491
		}
492 38
	}
493
494
	/**
495
	 * Checks and updates base and section urls
496
	 */
497 39
	private function setActiveButtons(): void
498
	{
499
		// If there are sections quickly goes through all the sections to check if the base menu has an url
500 39
		if (!empty($this->menuContext['current_section']))
501
		{
502 38
			$this->menuContext['sections'][$this->menuContext['current_section']]['selected'] = true;
503 38
			$this->menuContext['sections'][$this->menuContext['current_section']]['areas'][$this->currentArea]['selected'] =
504
				true;
505
506 38
			if (!empty($this->menuContext['sections'][$this->menuContext['current_section']]['areas'][$this->currentArea]['subsections'][$this->currentSubaction]))
507
			{
508
				$this->menuContext['sections'][$this->menuContext['current_section']]['areas'][$this->currentArea]['subsections'][$this->currentSubaction]['selected'] =
509
					true;
510
			}
511
		}
512 39
	}
513
514
	/**
515
	 * Add the base menu options for this menu
516
	 *
517
	 * @param array $menuOptions an array of options that can be used to override some default behaviours.
518
	 *                           It can accept the following indexes:
519
	 *                           - action                    => overrides the default action
520
	 *                           - current_area              => overrides the current area
521
	 *                           - extra_url_parameters      => an array or pairs or parameters to be added to the url
522
	 *                           - disable_url_session_check => (boolean) if true the session var/id are omitted from
523
	 *                           the url
524
	 *                           - base_url                  => an alternative base url
525
	 *                           - menu_type                 => alternative menu types?
526
	 *                           - can_toggle_drop_down      => (boolean) if the menu can "toggle"
527
	 *                           - template_name             => an alternative template to load (instead of Generic)
528
	 *                           - layer_name                => alternative layer name for the menu
529
	 *                           - hook                      => hook name to call integrate_ . 'hook name' . '_areas'
530
	 *                           - default_include_dir       => directory to include for function support
531
	 */
532 41
	public function addOptions(array $menuOptions): void
533
	{
534 41
		$this->menuOptions = MenuOptions::buildFromArray($menuOptions);
535 41
	}
536
537
	/**
538
	 * @param string      $id
539
	 * @param MenuSection $section
540
	 *
541
	 * @return $this
542
	 */
543 41
	public function addSection(string $id, MenuSection $section): Menu
544
	{
545 41
		$this->menuData[$id] = $section;
546
547 41
		return $this;
548
	}
549
550
	/**
551
	 * Finalizes items so the computed menu can be used
552
	 *
553
	 * What it does:
554
	 *   - Sets the menu layer in the template stack
555
	 *   - Loads context with the computed menu context
556
	 *   - Sets current subaction and current max menu id
557
	 */
558 38
	public function setContext(): void
559
	{
560 38
		global $context;
561
562
		// Almost there - load the template and add to the template layers.
563 38
		theme()->getTemplates()->load($this->menuOptions->getTemplateName());
564 38
		theme()->getLayers()->add($this->menuOptions->getLayerName());
565
566
		// Set it all to context for template consumption
567 38
		$this->menuContext['layer_name'] = $this->menuOptions->getLayerName();
568 38
		$this->menuContext['can_toggle_drop_down'] = $this->menuOptions->isDropDownToggleable();
569 38
		$context['max_menu_id'] = $this->maxMenuId;
570 38
		$context['current_subaction'] = $this->currentSubaction;
571 38
		$this->menuContext['current_subsection'] = $this->currentSubaction;
572 38
		$this->menuContext['current_area'] = $this->currentArea;
573 38
		$context['menu_data_' . $this->maxMenuId] = $this->menuContext;
574 38
	}
575
576
	/**
577
	 * Delete a menu.
578
	 *
579
	 * Checks to see if this menu been loaded into context
580
	 * and, if so, resets $context['max_menu_id'] back to the
581
	 * last known menu (if any) and remove the template layer
582
	 * if there aren't any other known menus.
583
	 */
584 21
	public function destroy(): void
585
	{
586 21
		global $context;
587
588
		// Has this menu been loaded into context?
589 21
		if (isset($context[$menuName = 'menu_data_' . $this->maxMenuId]))
590
		{
591
			// Decrement the pointer if this is the final menu in the series.
592 18
			if ($this->maxMenuId == $context['max_menu_id'])
593
			{
594 18
				$context['max_menu_id'] = max($context['max_menu_id'] - 1, 0);
595
			}
596
597
			// Remove the template layer if this was the only menu left.
598 18
			if ($context['max_menu_id'] == 0)
599
			{
600 17
				theme()->getLayers()->remove($context[$menuName]['layer_name']);
601
			}
602
603 18
			unset($context[$menuName]);
604
		}
605 21
	}
606
}
607