@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3abstract class PhabricatorProfileMenuEngine extends Phobject {
4
5 private $viewer;
6 private $profileObject;
7 private $customPHID;
8 private $items;
9 private $controller;
10 private $navigation;
11 private $editMode;
12 private $pageClasses = array();
13 private $showContentCrumbs = true;
14
15 const ITEM_CUSTOM_DIVIDER = 'engine.divider';
16 const ITEM_MANAGE = 'item.configure';
17
18 const MODE_COMBINED = 'combined';
19 const MODE_GLOBAL = 'global';
20 const MODE_CUSTOM = 'custom';
21
22 public function setViewer(PhabricatorUser $viewer) {
23 $this->viewer = $viewer;
24 return $this;
25 }
26
27 public function getViewer() {
28 return $this->viewer;
29 }
30
31 /**
32 * @param object $profile_object A PhabricatorApplication subclass
33 */
34 public function setProfileObject($profile_object) {
35 $this->profileObject = $profile_object;
36 return $this;
37 }
38
39 /**
40 * @return object A PhabricatorApplication subclass
41 */
42 public function getProfileObject() {
43 return $this->profileObject;
44 }
45
46 /**
47 * @param $custom_phid A User PHID
48 */
49 public function setCustomPHID($custom_phid) {
50 $this->customPHID = $custom_phid;
51 return $this;
52 }
53
54 /**
55 * @return string|null A User PHID, or null
56 */
57 public function getCustomPHID() {
58 return $this->customPHID;
59 }
60
61 /**
62 * @return string|null A User PHID, or null
63 */
64 private function getEditModeCustomPHID() {
65 $mode = $this->getEditMode();
66
67 switch ($mode) {
68 case self::MODE_CUSTOM:
69 $custom_phid = $this->getCustomPHID();
70 break;
71 case self::MODE_GLOBAL:
72 default:
73 $custom_phid = null;
74 break;
75 }
76
77 return $custom_phid;
78 }
79
80 public function setController(PhabricatorController $controller) {
81 $this->controller = $controller;
82 return $this;
83 }
84
85 public function getController() {
86 return $this->controller;
87 }
88
89 public function addContentPageClass($class) {
90 $this->pageClasses[] = $class;
91 return $this;
92 }
93
94 public function setShowContentCrumbs($show_content_crumbs) {
95 $this->showContentCrumbs = $show_content_crumbs;
96 return $this;
97 }
98
99 public function getShowContentCrumbs() {
100 return $this->showContentCrumbs;
101 }
102
103 abstract public function getItemURI($path);
104 abstract protected function isMenuEngineConfigurable();
105
106 /**
107 * @return array of PhabricatorProfileMenuItemConfiguration objects
108 */
109 abstract protected function getBuiltinProfileItems($object);
110
111 protected function getBuiltinCustomProfileItems(
112 $object,
113 $custom_phid) {
114 return array();
115 }
116
117 protected function getEditMode() {
118 return $this->editMode;
119 }
120
121 public function buildResponse() {
122 $controller = $this->getController();
123
124 $viewer = $controller->getViewer();
125 $this->setViewer($viewer);
126
127 $request = $controller->getRequest();
128
129 $item_action = $request->getURIData('itemAction');
130 if (!$item_action) {
131 $item_action = 'view';
132 }
133
134 $is_view = ($item_action == 'view');
135
136 // If the engine is not configurable, don't respond to any of the editing
137 // or configuration routes.
138 if (!$this->isMenuEngineConfigurable()) {
139 if (!$is_view) {
140 return new Aphront404Response();
141 }
142 }
143
144 $item_id = $request->getURIData('itemID');
145
146 // If we miss on the MenuEngine route, try the EditEngine route. This will
147 // be populated while editing items.
148 if (!$item_id) {
149 $item_id = $request->getURIData('id');
150 }
151
152 $view_list = $this->newProfileMenuItemViewList();
153
154 if ($is_view) {
155 $selected_item = $this->selectViewItem($view_list, $item_id);
156 } else {
157 if (!phutil_nonempty_scalar($item_id)) {
158 $item_id = self::ITEM_MANAGE;
159 }
160 $selected_item = $this->selectEditItem($view_list, $item_id);
161 }
162
163 switch ($item_action) {
164 case 'view':
165 // If we were not able to select an item, we're still going to render
166 // a page state. For example, this happens when you create a new
167 // portal for the first time.
168 break;
169 case 'info':
170 case 'hide':
171 case 'default':
172 case 'builtin':
173 if (!$selected_item) {
174 return new Aphront404Response();
175 }
176 break;
177 case 'edit':
178 if (!$request->getURIData('id')) {
179 // If we continue along the "edit" pathway without an ID, we hit an
180 // unrelated exception because we can not build a new menu item out
181 // of thin air. For menus, new items are created via the "new"
182 // action. Just catch this case and 404 early since there's currently
183 // no clean way to make EditEngine aware of this.
184 return new Aphront404Response();
185 }
186 break;
187 }
188
189 $navigation = $view_list->newNavigationView();
190 $crumbs = $controller->buildApplicationCrumbsForEditEngine();
191
192 if (!$is_view) {
193 $edit_mode = null;
194
195 if ($selected_item) {
196 if ($selected_item->getBuiltinKey() !== self::ITEM_MANAGE) {
197 if ($selected_item->getCustomPHID()) {
198 $edit_mode = 'custom';
199 } else {
200 $edit_mode = 'global';
201 }
202 }
203 }
204
205 if ($edit_mode === null) {
206 $edit_mode = $request->getURIData('itemEditMode');
207 }
208
209 $available_modes = $this->getViewerEditModes();
210 if ($available_modes) {
211 $available_modes = array_fuse($available_modes);
212 if ($edit_mode !== null && isset($available_modes[$edit_mode])) {
213 $this->editMode = $edit_mode;
214 } else {
215 if ($item_action != 'configure') {
216 return new Aphront404Response();
217 }
218 }
219 }
220 $page_title = pht('Configure Menu');
221 } else {
222 if ($selected_item) {
223 $page_title = $selected_item->getDisplayName();
224 } else {
225 $page_title = pht('Empty');
226 }
227 }
228
229 switch ($item_action) {
230 case 'view':
231 if ($selected_item) {
232 try {
233 $content = $this->buildItemViewContent($selected_item);
234 } catch (Exception $ex) {
235 $content = id(new PHUIInfoView())
236 ->setTitle(pht('Unable to Render Dashboard'))
237 ->setErrors(array($ex->getMessage()));
238 }
239
240 $crumbs->addTextCrumb($selected_item->getDisplayName());
241 } else {
242 $content = $this->newNoContentView($this->getItems());
243 }
244
245 if (!$content) {
246 $content = $this->newEmptyView(
247 pht('Empty'),
248 pht('There is nothing here.'));
249 }
250 break;
251 case 'configure':
252 $mode = $this->getEditMode();
253 if (!$mode) {
254 $crumbs->addTextCrumb(pht('Configure Menu'));
255 $content = $this->buildMenuEditModeContent();
256 } else {
257 if (count($available_modes) > 1) {
258 $crumbs->addTextCrumb(
259 pht('Configure Menu'),
260 $this->getItemURI('configure/'));
261
262 switch ($mode) {
263 case self::MODE_CUSTOM:
264 $crumbs->addTextCrumb(pht('Personal'));
265 break;
266 case self::MODE_GLOBAL:
267 $crumbs->addTextCrumb(pht('Global'));
268 break;
269 }
270 } else {
271 $crumbs->addTextCrumb(pht('Configure Menu'));
272 }
273 $edit_list = $this->loadItems($mode);
274 $content = $this->buildItemConfigureContent($edit_list);
275 }
276 break;
277 case 'reorder':
278 $mode = $this->getEditMode();
279 $edit_list = $this->loadItems($mode);
280 $content = $this->buildItemReorderContent($edit_list);
281 break;
282 case 'new':
283 $item_key = $request->getURIData('itemKey');
284 $mode = $this->getEditMode();
285 $content = $this->buildItemNewContent($item_key, $mode);
286 break;
287 case 'builtin':
288 $content = $this->buildItemBuiltinContent($selected_item);
289 break;
290 case 'hide':
291 $content = $this->buildItemHideContent($selected_item);
292 break;
293 case 'default':
294 if (!$this->isMenuEnginePinnable()) {
295 return new Aphront404Response();
296 }
297 $content = $this->buildItemDefaultContent($selected_item);
298 break;
299 case 'edit':
300 $content = $this->buildItemEditContent();
301 break;
302 default:
303 throw new Exception(
304 pht(
305 'Unsupported item action "%s".',
306 $item_action));
307 }
308
309 if ($content instanceof AphrontResponse) {
310 return $content;
311 }
312
313 if ($content instanceof AphrontResponseProducerInterface) {
314 return $content;
315 }
316
317 $crumbs->setBorder(true);
318
319 $page = $controller->newPage()
320 ->setTitle($page_title)
321 ->appendChild($content);
322
323 if (!$is_view || $this->getShowContentCrumbs()) {
324 $page->setCrumbs($crumbs);
325 }
326
327 $page->setNavigation($navigation);
328
329 if ($is_view) {
330 foreach ($this->pageClasses as $class) {
331 $page->addClass($class);
332 }
333 }
334
335 return $page;
336 }
337
338 private function getItems() {
339 if ($this->items === null) {
340 $this->items = $this->loadItems(self::MODE_COMBINED);
341 }
342
343 return $this->items;
344 }
345
346 private function loadItems($mode) {
347 $viewer = $this->getViewer();
348 $object = $this->getProfileObject();
349
350 $items = $this->loadBuiltinProfileItems($mode);
351
352 $query = id(new PhabricatorProfileMenuItemConfigurationQuery())
353 ->setViewer($viewer)
354 ->withProfilePHIDs(array($object->getPHID()));
355
356 switch ($mode) {
357 case self::MODE_GLOBAL:
358 $query->withCustomPHIDs(array(), true);
359 break;
360 case self::MODE_CUSTOM:
361 $query->withCustomPHIDs(array($this->getCustomPHID()), false);
362 break;
363 case self::MODE_COMBINED:
364 $query->withCustomPHIDs(array($this->getCustomPHID()), true);
365 break;
366 }
367
368 $stored_items = $query->execute();
369
370 foreach ($stored_items as $stored_item) {
371 $impl = $stored_item->getMenuItem();
372 $impl->setViewer($viewer);
373 $impl->setEngine($this);
374 }
375
376 // Merge the stored items into the builtin items. If a builtin item has
377 // a stored version, replace the defaults with the stored changes.
378 foreach ($stored_items as $stored_item) {
379 if (!$stored_item->shouldEnableForObject($object)) {
380 continue;
381 }
382
383 $builtin_key = $stored_item->getBuiltinKey();
384 if ($builtin_key !== null) {
385 // If this builtin actually exists, replace the builtin with the
386 // stored configuration. Otherwise, we're just going to drop the
387 // stored config: it corresponds to an out-of-date or uninstalled
388 // item.
389 if (isset($items[$builtin_key])) {
390 $builtin_item = $items[$builtin_key];
391
392 // Copy runtime properties from the builtin item to the stored item.
393 $stored_item->setIsHeadItem($builtin_item->getIsHeadItem());
394 $stored_item->setIsTailItem($builtin_item->getIsTailItem());
395
396 $items[$builtin_key] = $stored_item;
397 } else {
398 continue;
399 }
400 } else {
401 $items[] = $stored_item;
402 }
403 }
404
405 return $this->arrangeItems($items, $mode);
406 }
407
408 private function loadBuiltinProfileItems($mode) {
409 $object = $this->getProfileObject();
410 $builtins = array();
411
412 switch ($mode) {
413 case self::MODE_GLOBAL:
414 $builtins = $this->getBuiltinProfileItems($object);
415 break;
416 case self::MODE_CUSTOM:
417 $builtins = $this->getBuiltinCustomProfileItems(
418 $object,
419 $this->getCustomPHID());
420 break;
421 case self::MODE_COMBINED:
422 $builtins[] = $this->getBuiltinCustomProfileItems(
423 $object,
424 $this->getCustomPHID());
425 $builtins[] = $this->getBuiltinProfileItems($object);
426 $builtins = array_mergev($builtins);
427 break;
428 }
429
430 $items = PhabricatorProfileMenuItem::getAllMenuItems();
431 $viewer = $this->getViewer();
432
433 $order = 1;
434 $map = array();
435 foreach ($builtins as $builtin) {
436 $builtin_key = $builtin->getBuiltinKey();
437
438 if (!$builtin_key) {
439 throw new Exception(
440 pht(
441 'Object produced a builtin item with no builtin item key! '.
442 'Builtin items must have a unique key.'));
443 }
444
445 if (isset($map[$builtin_key])) {
446 throw new Exception(
447 pht(
448 'Object produced two items with the same builtin key ("%s"). '.
449 'Each item must have a unique builtin key.',
450 $builtin_key));
451 }
452
453 $item_key = $builtin->getMenuItemKey();
454
455 $item = idx($items, $item_key);
456 if (!$item) {
457 throw new Exception(
458 pht(
459 'Builtin item ("%s") specifies a bad item key ("%s"); there '.
460 'is no corresponding item implementation available.',
461 $builtin_key,
462 $item_key));
463 }
464
465 $item = clone $item;
466 $item->setViewer($viewer);
467 $item->setEngine($this);
468
469 $builtin
470 ->setProfilePHID($object->getPHID())
471 ->attachMenuItem($item)
472 ->attachProfileObject($object)
473 ->setMenuItemOrder($order);
474
475 if (!$builtin->shouldEnableForObject($object)) {
476 continue;
477 }
478
479 $map[$builtin_key] = $builtin;
480
481 $order++;
482 }
483
484 return $map;
485 }
486
487 public function getConfigureURI() {
488 $mode = $this->getEditMode();
489
490 switch ($mode) {
491 case self::MODE_CUSTOM:
492 return $this->getItemURI('configure/custom/');
493 case self::MODE_GLOBAL:
494 return $this->getItemURI('configure/global/');
495 }
496
497 return $this->getItemURI('configure/');
498 }
499
500 private function buildItemReorderContent(array $items) {
501 $viewer = $this->getViewer();
502 $object = $this->getProfileObject();
503
504 // If you're reordering global items, you need to be able to edit the
505 // object the menu appears on. If you're reordering custom items, you only
506 // need to be able to edit the custom object. Currently, the custom object
507 // is always the viewing user's own user object.
508 $custom_phid = $this->getEditModeCustomPHID();
509
510 if (!$custom_phid) {
511 PhabricatorPolicyFilter::requireCapability(
512 $viewer,
513 $object,
514 PhabricatorPolicyCapability::CAN_EDIT);
515 } else {
516 $policy_object = id(new PhabricatorObjectQuery())
517 ->setViewer($viewer)
518 ->withPHIDs(array($custom_phid))
519 ->executeOne();
520
521 if (!$policy_object) {
522 throw new Exception(
523 pht(
524 'Failed to load custom PHID "%s"!',
525 $custom_phid));
526 }
527
528 PhabricatorPolicyFilter::requireCapability(
529 $viewer,
530 $policy_object,
531 PhabricatorPolicyCapability::CAN_EDIT);
532 }
533
534 $controller = $this->getController();
535 $request = $controller->getRequest();
536
537 $request->validateCSRF();
538
539 $order = $request->getStrList('order');
540
541 $by_builtin = array();
542 $by_id = array();
543
544 foreach ($items as $key => $item) {
545 $id = $item->getID();
546 if ($id) {
547 $by_id[$id] = $key;
548 continue;
549 }
550
551 $builtin_key = $item->getBuiltinKey();
552 if ($builtin_key) {
553 $by_builtin[$builtin_key] = $key;
554 continue;
555 }
556 }
557
558 $key_order = array();
559 foreach ($order as $order_item) {
560 if (isset($by_id[$order_item])) {
561 $key_order[] = $by_id[$order_item];
562 continue;
563 }
564 if (isset($by_builtin[$order_item])) {
565 $key_order[] = $by_builtin[$order_item];
566 continue;
567 }
568 }
569
570 $items = array_select_keys($items, $key_order) + $items;
571
572 $type_order =
573 PhabricatorProfileMenuItemConfigurationTransaction::TYPE_ORDER;
574
575 $order = 1;
576 foreach ($items as $item) {
577 $xactions = array();
578
579 $xactions[] = id(new PhabricatorProfileMenuItemConfigurationTransaction())
580 ->setTransactionType($type_order)
581 ->setNewValue($order);
582
583 $editor = id(new PhabricatorProfileMenuEditor())
584 ->setContentSourceFromRequest($request)
585 ->setActor($viewer)
586 ->setContinueOnMissingFields(true)
587 ->setContinueOnNoEffect(true)
588 ->applyTransactions($item, $xactions);
589
590 $order++;
591 }
592
593 return id(new AphrontRedirectResponse())
594 ->setURI($this->getConfigureURI());
595 }
596
597 protected function buildItemViewContent(
598 PhabricatorProfileMenuItemConfiguration $item) {
599 return $item->newPageContent();
600 }
601
602 private function getViewerEditModes() {
603 $modes = array();
604
605 $viewer = $this->getViewer();
606
607 if ($viewer->isLoggedIn() && $this->isMenuEnginePersonalizable()) {
608 $modes[] = self::MODE_CUSTOM;
609 }
610
611 $object = $this->getProfileObject();
612 $can_edit = PhabricatorPolicyFilter::hasCapability(
613 $viewer,
614 $object,
615 PhabricatorPolicyCapability::CAN_EDIT);
616
617 if ($can_edit) {
618 $modes[] = self::MODE_GLOBAL;
619 }
620
621 return $modes;
622 }
623
624 protected function isMenuEnginePersonalizable() {
625 return true;
626 }
627
628 /**
629 * Does this engine support pinning items?
630 *
631 * Personalizable menus disable pinning by default since it creates a number
632 * of weird edge cases without providing many benefits for current menus.
633 *
634 * @return bool True if items may be pinned as default items.
635 */
636 public function isMenuEnginePinnable() {
637 return !$this->isMenuEnginePersonalizable();
638 }
639
640 private function buildMenuEditModeContent() {
641 $viewer = $this->getViewer();
642
643 $modes = $this->getViewerEditModes();
644 if (!$modes) {
645 return new Aphront404Response();
646 }
647
648 if (count($modes) == 1) {
649 $mode = head($modes);
650 return id(new AphrontRedirectResponse())
651 ->setURI($this->getItemURI("configure/{$mode}/"));
652 }
653
654 $menu = id(new PHUIObjectItemListView())
655 ->setUser($viewer);
656
657 $modes = array_fuse($modes);
658
659 if (isset($modes['custom'])) {
660 $menu->addItem(
661 id(new PHUIObjectItemView())
662 ->setHeader(pht('Personal Menu Items'))
663 ->setHref($this->getItemURI('configure/custom/'))
664 ->setImageURI($viewer->getProfileImageURI())
665 ->addAttribute(pht('Edit the menu for your personal account.')));
666 }
667
668 if (isset($modes['global'])) {
669 $icon = id(new PHUIIconView())
670 ->setIcon('fa-globe')
671 ->setBackground('bg-blue');
672
673 $menu->addItem(
674 id(new PHUIObjectItemView())
675 ->setHeader(pht('Global Menu Items'))
676 ->setHref($this->getItemURI('configure/global/'))
677 ->setImageIcon($icon)
678 ->addAttribute(pht('Edit the global default menu for all users.')));
679 }
680
681 $box = id(new PHUIObjectBoxView())
682 ->setObjectList($menu);
683
684 $header = id(new PHUIHeaderView())
685 ->setHeader(pht('Manage Menu'))
686 ->setHeaderIcon('fa-list');
687
688 return id(new PHUITwoColumnView())
689 ->setHeader($header)
690 ->setFooter($box);
691 }
692
693 private function buildItemConfigureContent(array $items) {
694 $viewer = $this->getViewer();
695 $object = $this->getProfileObject();
696
697 $filtered_groups = mgroup($items, 'getMenuItemKey');
698 foreach ($filtered_groups as $group) {
699 $first_item = head($group);
700 $first_item->willGetMenuItemViewList($group);
701 }
702
703 // Users only need to be able to edit the object which this menu appears
704 // on if they're editing global menu items. For example, users do not need
705 // to be able to edit the Favorites application to add new items to the
706 // Favorites menu.
707 if (!$this->getCustomPHID()) {
708 PhabricatorPolicyFilter::requireCapability(
709 $viewer,
710 $object,
711 PhabricatorPolicyCapability::CAN_EDIT);
712 }
713
714 $list_id = celerity_generate_unique_node_id();
715
716 $mode = $this->getEditMode();
717
718 Javelin::initBehavior(
719 'reorder-profile-menu-items',
720 array(
721 'listID' => $list_id,
722 'orderURI' => $this->getItemURI("reorder/{$mode}/"),
723 ));
724
725 $list = id(new PHUIObjectItemListView())
726 ->setID($list_id)
727 ->setNoDataString(pht('This menu currently has no items.'));
728
729 $any_draggable = false;
730 foreach ($items as $item) {
731 $id = $item->getID();
732 $builtin_key = $item->getBuiltinKey();
733
734 $can_edit = PhabricatorPolicyFilter::hasCapability(
735 $viewer,
736 $item,
737 PhabricatorPolicyCapability::CAN_EDIT);
738
739 $view = new PHUIObjectItemView();
740
741 $name = $item->getDisplayName();
742 $type = $item->getMenuItemTypeName();
743 if (!strlen(trim($name))) {
744 $name = pht('Untitled "%s" Item', $type);
745 }
746
747 $view->setHeader($name);
748 $view->addAttribute($type);
749
750 $icon = $item->getMenuItem()->getMenuItemTypeIcon();
751 if ($icon !== null) {
752 $view->setStatusIcon($icon);
753 }
754
755 if ($can_edit) {
756 $can_move = (!$item->getIsHeadItem() && !$item->getIsTailItem());
757 if ($can_move) {
758 $view
759 ->setGrippable(true)
760 ->addSigil('profile-menu-item')
761 ->setMetadata(
762 array(
763 'key' => nonempty($id, $builtin_key),
764 ));
765 $any_draggable = true;
766 } else {
767 $view->setGrippable(false);
768 }
769
770 if ($id) {
771 $default_uri = $this->getItemURI("default/{$id}/");
772 } else {
773 $default_uri = $this->getItemURI("default/{$builtin_key}/");
774 }
775
776 $default_text = null;
777
778 if ($this->isMenuEnginePinnable()) {
779 if ($item->isDefault()) {
780 $default_icon = 'fa-thumb-tack green';
781 $default_text = pht('Current Default');
782 } else if ($item->canMakeDefault()) {
783 $default_icon = 'fa-thumb-tack';
784 $default_text = pht('Make Default');
785 }
786 }
787
788 if ($default_text !== null) {
789 $view->addAction(
790 id(new PHUIListItemView())
791 ->setHref($default_uri)
792 ->setWorkflow(true)
793 ->setName($default_text)
794 ->setIcon($default_icon));
795 }
796
797 if ($id) {
798 $view->setHref($this->getItemURI("edit/{$id}/"));
799 $hide_uri = $this->getItemURI("hide/{$id}/");
800 } else {
801 $view->setHref($this->getItemURI("builtin/{$builtin_key}/"));
802 $hide_uri = $this->getItemURI("hide/{$builtin_key}/");
803 }
804
805 if ($item->isDisabled()) {
806 $hide_icon = 'fa-plus';
807 $hide_text = pht('Enable');
808 } else if ($item->getBuiltinKey() !== null) {
809 $hide_icon = 'fa-times';
810 $hide_text = pht('Disable');
811 } else {
812 $hide_icon = 'fa-times';
813 $hide_text = pht('Delete');
814 }
815
816 $can_disable = $item->canHideMenuItem();
817
818 $view->addAction(
819 id(new PHUIListItemView())
820 ->setHref($hide_uri)
821 ->setWorkflow(true)
822 ->setDisabled(!$can_disable)
823 ->setName($hide_text)
824 ->setIcon($hide_icon));
825 }
826
827 if ($item->isDisabled()) {
828 $view->setDisabled(true);
829 }
830
831 $list->addItem($view);
832 }
833
834 $item_types = PhabricatorProfileMenuItem::getAllMenuItems();
835 $object = $this->getProfileObject();
836
837 $action_list = id(new PhabricatorActionListView())
838 ->setViewer($viewer);
839
840 // See T12167. This makes the "Actions" dropdown button show up in the
841 // page header.
842 $action_list->setID(celerity_generate_unique_node_id());
843
844 $action_list->addAction(
845 id(new PhabricatorActionView())
846 ->setLabel(true)
847 ->setName(pht('Add New Menu Item...')));
848
849 foreach ($item_types as $item_type) {
850 if (!$item_type->canAddToObject($object)) {
851 continue;
852 }
853
854 $item_key = $item_type->getMenuItemKey();
855 $edit_mode = $this->getEditMode();
856
857 $action_list->addAction(
858 id(new PhabricatorActionView())
859 ->setIcon($item_type->getMenuItemTypeIcon())
860 ->setName($item_type->getMenuItemTypeName())
861 ->setHref($this->getItemURI("new/{$edit_mode}/{$item_key}/"))
862 ->setWorkflow(true));
863 }
864
865 $action_list->addAction(
866 id(new PhabricatorActionView())
867 ->setLabel(true)
868 ->setName(pht('Documentation')));
869
870 $doc_link = PhabricatorEnv::getDoclink('Profile Menu User Guide');
871 $doc_name = pht('Profile Menu User Guide');
872
873 $action_list->addAction(
874 id(new PhabricatorActionView())
875 ->setIcon('fa-book')
876 ->setHref($doc_link)
877 ->setName($doc_name));
878
879 $header = id(new PHUIHeaderView())
880 ->setHeader(pht('Menu Items'))
881 ->setHeaderIcon('fa-list');
882
883 $list_header = id(new PHUIHeaderView())
884 ->setHeader(pht('Current Menu Items'));
885
886 if ($any_draggable) {
887 $list_header->setSubheader(
888 pht('Drag items in this list to reorder them.'));
889 }
890
891 $box = id(new PHUIObjectBoxView())
892 ->setHeader($list_header)
893 ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
894 ->setObjectList($list);
895
896 $curtain = id(new PHUICurtainView())
897 ->setViewer($viewer)
898 ->setActionList($action_list);
899
900 $view = id(new PHUITwoColumnView())
901 ->setHeader($header)
902 ->setCurtain($curtain)
903 ->setMainColumn(
904 array(
905 $box,
906 ));
907
908 return $view;
909 }
910
911 private function buildItemNewContent($item_key, $mode) {
912 $item_types = PhabricatorProfileMenuItem::getAllMenuItems();
913 $item_type = idx($item_types, $item_key);
914 if (!$item_type) {
915 return new Aphront404Response();
916 }
917
918 $object = $this->getProfileObject();
919 if (!$item_type->canAddToObject($object)) {
920 return new Aphront404Response();
921 }
922
923 $custom_phid = $this->getEditModeCustomPHID();
924
925 $configuration = PhabricatorProfileMenuItemConfiguration::initializeNewItem(
926 $object,
927 $item_type,
928 $custom_phid);
929
930 $viewer = $this->getViewer();
931
932 PhabricatorPolicyFilter::requireCapability(
933 $viewer,
934 $configuration,
935 PhabricatorPolicyCapability::CAN_EDIT);
936
937 $controller = $this->getController();
938
939 return id(new PhabricatorProfileMenuEditEngine())
940 ->setMenuEngine($this)
941 ->setProfileObject($object)
942 ->setNewMenuItemConfiguration($configuration)
943 ->setCustomPHID($custom_phid)
944 ->setController($controller)
945 ->buildResponse();
946 }
947
948 private function buildItemEditContent() {
949 $viewer = $this->getViewer();
950 $object = $this->getProfileObject();
951 $controller = $this->getController();
952 $custom_phid = $this->getEditModeCustomPHID();
953
954 return id(new PhabricatorProfileMenuEditEngine())
955 ->setMenuEngine($this)
956 ->setProfileObject($object)
957 ->setController($controller)
958 ->setCustomPHID($custom_phid)
959 ->buildResponse();
960 }
961
962 private function buildItemBuiltinContent(
963 PhabricatorProfileMenuItemConfiguration $configuration) {
964
965 // If this builtin item has already been persisted, redirect to the
966 // edit page.
967 $id = $configuration->getID();
968 if ($id) {
969 return id(new AphrontRedirectResponse())
970 ->setURI($this->getItemURI("edit/{$id}/"));
971 }
972
973 // Otherwise, act like we're creating a new item, we're just starting
974 // with the builtin template.
975 $viewer = $this->getViewer();
976
977 PhabricatorPolicyFilter::requireCapability(
978 $viewer,
979 $configuration,
980 PhabricatorPolicyCapability::CAN_EDIT);
981
982 $object = $this->getProfileObject();
983 $controller = $this->getController();
984 $custom_phid = $this->getEditModeCustomPHID();
985
986 return id(new PhabricatorProfileMenuEditEngine())
987 ->setIsBuiltin(true)
988 ->setMenuEngine($this)
989 ->setProfileObject($object)
990 ->setNewMenuItemConfiguration($configuration)
991 ->setController($controller)
992 ->setCustomPHID($custom_phid)
993 ->buildResponse();
994 }
995
996 private function buildItemHideContent(
997 PhabricatorProfileMenuItemConfiguration $configuration) {
998
999 $controller = $this->getController();
1000 $request = $controller->getRequest();
1001 $viewer = $this->getViewer();
1002
1003 PhabricatorPolicyFilter::requireCapability(
1004 $viewer,
1005 $configuration,
1006 PhabricatorPolicyCapability::CAN_EDIT);
1007
1008 if (!$configuration->canHideMenuItem()) {
1009 return $controller->newDialog()
1010 ->setTitle(pht('Mandatory Item'))
1011 ->appendParagraph(
1012 pht('This menu item is very important, and can not be disabled.'))
1013 ->addCancelButton($this->getConfigureURI());
1014 }
1015
1016 if ($configuration->getBuiltinKey() === null) {
1017 $new_value = null;
1018
1019 $title = pht('Delete Menu Item');
1020 $body = pht('Delete this menu item?');
1021 $button = pht('Delete Menu Item');
1022 } else if ($configuration->isDisabled()) {
1023 $new_value = PhabricatorProfileMenuItemConfiguration::VISIBILITY_VISIBLE;
1024
1025 $title = pht('Enable Menu Item');
1026 $body = pht(
1027 'Enable this menu item? It will appear in the menu again.');
1028 $button = pht('Enable Menu Item');
1029 } else {
1030 $new_value = PhabricatorProfileMenuItemConfiguration::VISIBILITY_DISABLED;
1031
1032 $title = pht('Disable Menu Item');
1033 $body = pht(
1034 'Disable this menu item? It will no longer appear in the menu, but '.
1035 'you can re-enable it later.');
1036 $button = pht('Disable Menu Item');
1037 }
1038
1039 $v_visibility = $configuration->getVisibility();
1040 if ($request->isFormPost()) {
1041 if ($new_value === null) {
1042 $configuration->delete();
1043 } else {
1044 $type_visibility =
1045 PhabricatorProfileMenuItemConfigurationTransaction::TYPE_VISIBILITY;
1046
1047 $xactions = array();
1048
1049 $xactions[] =
1050 id(new PhabricatorProfileMenuItemConfigurationTransaction())
1051 ->setTransactionType($type_visibility)
1052 ->setNewValue($new_value);
1053
1054 $editor = id(new PhabricatorProfileMenuEditor())
1055 ->setContentSourceFromRequest($request)
1056 ->setActor($viewer)
1057 ->setContinueOnMissingFields(true)
1058 ->setContinueOnNoEffect(true)
1059 ->applyTransactions($configuration, $xactions);
1060 }
1061
1062 return id(new AphrontRedirectResponse())
1063 ->setURI($this->getConfigureURI());
1064 }
1065
1066 return $controller->newDialog()
1067 ->setTitle($title)
1068 ->appendParagraph($body)
1069 ->addCancelButton($this->getConfigureURI())
1070 ->addSubmitButton($button);
1071 }
1072
1073 private function buildItemDefaultContent(
1074 PhabricatorProfileMenuItemConfiguration $configuration) {
1075
1076 $controller = $this->getController();
1077 $request = $controller->getRequest();
1078 $viewer = $this->getViewer();
1079
1080 PhabricatorPolicyFilter::requireCapability(
1081 $viewer,
1082 $configuration,
1083 PhabricatorPolicyCapability::CAN_EDIT);
1084
1085 $done_uri = $this->getConfigureURI();
1086
1087 if (!$configuration->canMakeDefault()) {
1088 return $controller->newDialog()
1089 ->setTitle(pht('Not Defaultable'))
1090 ->appendParagraph(
1091 pht(
1092 'This item can not be set as the default item. This is usually '.
1093 'because the item has no page of its own, or links to an '.
1094 'external page.'))
1095 ->addCancelButton($done_uri);
1096 }
1097
1098 if ($configuration->isDefault()) {
1099 return $controller->newDialog()
1100 ->setTitle(pht('Already Default'))
1101 ->appendParagraph(
1102 pht(
1103 'This item is already set as the default item for this menu.'))
1104 ->addCancelButton($done_uri);
1105 }
1106
1107 if ($request->isFormPost()) {
1108 $key = $configuration->getID();
1109 if (!$key) {
1110 $key = $configuration->getBuiltinKey();
1111 }
1112
1113 $this->adjustDefault($key);
1114
1115 return id(new AphrontRedirectResponse())
1116 ->setURI($done_uri);
1117 }
1118
1119 return $controller->newDialog()
1120 ->setTitle(pht('Make Default'))
1121 ->appendParagraph(
1122 pht(
1123 'Set this item as the default for this menu? Users arriving on '.
1124 'this page will be shown the content of this item by default.'))
1125 ->addCancelButton($done_uri)
1126 ->addSubmitButton(pht('Make Default'));
1127 }
1128
1129 protected function newItem() {
1130 return PhabricatorProfileMenuItemConfiguration::initializeNewBuiltin();
1131 }
1132
1133 protected function newManageItem() {
1134 return $this->newItem()
1135 ->setBuiltinKey(self::ITEM_MANAGE)
1136 ->setMenuItemKey(PhabricatorManageProfileMenuItem::MENUITEMKEY)
1137 ->setIsTailItem(true);
1138 }
1139
1140 protected function newDividerItem($key) {
1141 return $this->newItem()
1142 ->setBuiltinKey($key)
1143 ->setMenuItemKey(PhabricatorDividerProfileMenuItem::MENUITEMKEY)
1144 ->setIsTailItem(true);
1145 }
1146
1147 public function getDefaultMenuItemConfiguration() {
1148 $configs = $this->getItems();
1149 foreach ($configs as $config) {
1150 if ($config->isDefault()) {
1151 return $config;
1152 }
1153 }
1154
1155 return null;
1156 }
1157
1158 public function adjustDefault($key) {
1159 $controller = $this->getController();
1160 $request = $controller->getRequest();
1161 $viewer = $request->getViewer();
1162
1163 $items = $this->loadItems(self::MODE_COMBINED);
1164
1165 // To adjust the default item, we first change any existing items that
1166 // are marked as defaults to "visible", then make the new default item
1167 // the default.
1168
1169 $default = array();
1170 $visible = array();
1171
1172 foreach ($items as $item) {
1173 $builtin_key = $item->getBuiltinKey();
1174 $id = $item->getID();
1175
1176 $is_target =
1177 (($builtin_key !== null) && ($builtin_key === $key)) ||
1178 (($id !== null) && ((int)$id === (int)$key));
1179
1180 if ($is_target) {
1181 if (!$item->isDefault()) {
1182 $default[] = $item;
1183 }
1184 } else {
1185 if ($item->isDefault()) {
1186 $visible[] = $item;
1187 }
1188 }
1189 }
1190
1191 $type_visibility =
1192 PhabricatorProfileMenuItemConfigurationTransaction::TYPE_VISIBILITY;
1193
1194 $v_visible = PhabricatorProfileMenuItemConfiguration::VISIBILITY_VISIBLE;
1195 $v_default = PhabricatorProfileMenuItemConfiguration::VISIBILITY_DEFAULT;
1196
1197 $apply = array(
1198 array($v_visible, $visible),
1199 array($v_default, $default),
1200 );
1201
1202 foreach ($apply as $group) {
1203 list($value, $items) = $group;
1204 foreach ($items as $item) {
1205 $xactions = array();
1206
1207 $xactions[] =
1208 id(new PhabricatorProfileMenuItemConfigurationTransaction())
1209 ->setTransactionType($type_visibility)
1210 ->setNewValue($value);
1211
1212 $editor = id(new PhabricatorProfileMenuEditor())
1213 ->setContentSourceFromRequest($request)
1214 ->setActor($viewer)
1215 ->setContinueOnMissingFields(true)
1216 ->setContinueOnNoEffect(true)
1217 ->applyTransactions($item, $xactions);
1218 }
1219 }
1220
1221 return $this;
1222 }
1223
1224 private function arrangeItems(array $items, $mode) {
1225 // Sort the items.
1226 $items = msortv($items, 'getSortVector');
1227
1228 $object = $this->getProfileObject();
1229
1230 // If we have some global items and some custom items and are in "combined"
1231 // mode, put a hard-coded divider item between them.
1232 if ($mode == self::MODE_COMBINED) {
1233 $list = array();
1234 $seen_custom = false;
1235 $seen_global = false;
1236 foreach ($items as $item) {
1237 if ($item->getCustomPHID()) {
1238 $seen_custom = true;
1239 } else {
1240 if ($seen_custom && !$seen_global) {
1241 $list[] = $this->newItem()
1242 ->setBuiltinKey(self::ITEM_CUSTOM_DIVIDER)
1243 ->setMenuItemKey(PhabricatorDividerProfileMenuItem::MENUITEMKEY)
1244 ->attachProfileObject($object)
1245 ->attachMenuItem(
1246 new PhabricatorDividerProfileMenuItem());
1247 }
1248 $seen_global = true;
1249 }
1250 $list[] = $item;
1251 }
1252 $items = $list;
1253 }
1254
1255 // Normalize keys since callers shouldn't rely on this array being
1256 // partially keyed.
1257 $items = array_values($items);
1258
1259 return $items;
1260 }
1261
1262 final protected function newEmptyView($title, $message) {
1263 return id(new PHUIInfoView())
1264 ->setTitle($title)
1265 ->setSeverity(PHUIInfoView::SEVERITY_NODATA)
1266 ->setErrors(
1267 array(
1268 $message,
1269 ));
1270 }
1271
1272 protected function newNoContentView(array $items) {
1273 return $this->newEmptyView(
1274 pht('No Content'),
1275 pht('No visible menu items can render content.'));
1276 }
1277
1278
1279 final public function newProfileMenuItemViewList() {
1280 $items = $this->getItems();
1281
1282 // Throw away disabled items: they are not allowed to build any views for
1283 // the menu.
1284 foreach ($items as $key => $item) {
1285 if ($item->isDisabled()) {
1286 unset($items[$key]);
1287 continue;
1288 }
1289 }
1290
1291 // Give each item group a callback so it can load data it needs to render
1292 // views.
1293 $groups = mgroup($items, 'getMenuItemKey');
1294 foreach ($groups as $group) {
1295 $item = head($group);
1296 $item->willGetMenuItemViewList($group);
1297 }
1298
1299 $view_list = id(new PhabricatorProfileMenuItemViewList())
1300 ->setProfileMenuEngine($this);
1301
1302 foreach ($items as $item) {
1303 $views = $item->getMenuItemViewList();
1304 foreach ($views as $view) {
1305 $view_list->addItemView($view);
1306 }
1307 }
1308
1309 return $view_list;
1310 }
1311
1312 private function selectViewItem(
1313 PhabricatorProfileMenuItemViewList $view_list,
1314 $item_id) {
1315
1316 // Figure out which view's content we're going to render. In most cases,
1317 // the URI tells us. If we don't have an identifier in the URI, we'll
1318 // render the default view instead.
1319
1320 $selected_view = null;
1321 if (phutil_nonempty_string($item_id)) {
1322 $item_views = $view_list->getViewsWithItemIdentifier($item_id);
1323 if ($item_views) {
1324 $selected_view = head($item_views);
1325 }
1326 } else {
1327 $default_views = $view_list->getDefaultViews();
1328 if ($default_views) {
1329 $selected_view = head($default_views);
1330 }
1331 }
1332
1333 if ($selected_view) {
1334 $view_list->setSelectedView($selected_view);
1335 $selected_item = $selected_view->getMenuItemConfiguration();
1336 } else {
1337 $selected_item = null;
1338 }
1339
1340 return $selected_item;
1341 }
1342
1343 private function selectEditItem(
1344 PhabricatorProfileMenuItemViewList $view_list,
1345 $item_id) {
1346
1347 // First, try to select a visible item using the normal view selection
1348 // pathway. If this works, it also highlights the menu properly.
1349
1350 if ($item_id) {
1351 $selected_item = $this->selectViewItem($view_list, $item_id);
1352 if ($selected_item) {
1353 return $selected_item;
1354 }
1355 }
1356
1357 // If we didn't find an item in the view list, we may be enabling an item
1358 // which is currently disabled or editing an item which is not generating
1359 // any actual items in the menu.
1360
1361 foreach ($this->getItems() as $item) {
1362 if ($item->matchesIdentifier($item_id)) {
1363 return $item;
1364 }
1365 }
1366
1367 return null;
1368 }
1369
1370
1371}