diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index d93bf72ff4..617939d393 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -4056,6 +4056,8 @@ phutil_register_library_map(array( 'PhabricatorProfileMenuItemConfigurationTransactionQuery' => 'applications/search/query/PhabricatorProfileMenuItemConfigurationTransactionQuery.php', 'PhabricatorProfileMenuItemIconSet' => 'applications/search/menuitem/PhabricatorProfileMenuItemIconSet.php', 'PhabricatorProfileMenuItemPHIDType' => 'applications/search/phidtype/PhabricatorProfileMenuItemPHIDType.php', + 'PhabricatorProfileMenuItemView' => 'applications/search/engine/PhabricatorProfileMenuItemView.php', + 'PhabricatorProfileMenuItemViewList' => 'applications/search/engine/PhabricatorProfileMenuItemViewList.php', 'PhabricatorProject' => 'applications/project/storage/PhabricatorProject.php', 'PhabricatorProjectAddHeraldAction' => 'applications/project/herald/PhabricatorProjectAddHeraldAction.php', 'PhabricatorProjectApplication' => 'applications/project/application/PhabricatorProjectApplication.php', @@ -10184,6 +10186,8 @@ phutil_register_library_map(array( 'PhabricatorProfileMenuItemConfigurationTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProfileMenuItemIconSet' => 'PhabricatorIconSet', 'PhabricatorProfileMenuItemPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorProfileMenuItemView' => 'Phobject', + 'PhabricatorProfileMenuItemViewList' => 'Phobject', 'PhabricatorProject' => array( 'PhabricatorProjectDAO', 'PhabricatorApplicationTransactionInterface', diff --git a/src/applications/search/editor/PhabricatorProfileMenuEditEngine.php b/src/applications/search/editor/PhabricatorProfileMenuEditEngine.php index b21d03cbd1..b55f43255c 100644 --- a/src/applications/search/editor/PhabricatorProfileMenuEditEngine.php +++ b/src/applications/search/editor/PhabricatorProfileMenuEditEngine.php @@ -109,7 +109,7 @@ final class PhabricatorProfileMenuEditEngine } protected function getObjectEditTitleText($object) { - $object->willBuildNavigationItems(array($object)); + $object->willGetMenuItemViewList(array($object)); return pht('Edit Menu Item: %s', $object->getDisplayName()); } diff --git a/src/applications/search/engine/PhabricatorProfileMenuEngine.php b/src/applications/search/engine/PhabricatorProfileMenuEngine.php index abb6de789b..5e89f22b16 100644 --- a/src/applications/search/engine/PhabricatorProfileMenuEngine.php +++ b/src/applications/search/engine/PhabricatorProfileMenuEngine.php @@ -71,16 +71,6 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { return $this->controller; } - private function setDefaultItem( - PhabricatorProfileMenuItemConfiguration $default_item) { - $this->defaultItem = $default_item; - return $this; - } - - public function getDefaultItem() { - return $this->pickDefaultItem($this->getItems()); - } - public function setShowNavigation($show) { $this->showNavigation = $show; return $this; @@ -150,10 +140,10 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { $item_id = $request->getURIData('id'); } - $item_list = $this->getItems(); + $view_list = $this->newProfileMenuItemViewList(); - $selected_item = $this->pickSelectedItem( - $item_list, + $selected_item = $this->selectItem( + $view_list, $item_id, $is_view); @@ -183,8 +173,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { break; } - $navigation = $this->buildNavigation($selected_item); - + $navigation = $view_list->newNavigationView(); $crumbs = $controller->buildApplicationCrumbsForEditEngine(); if (!$is_view) { @@ -288,9 +277,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { if (!$this->isMenuEnginePinnable()) { return new Aphront404Response(); } - $content = $this->buildItemDefaultContent( - $selected_item, - $item_list); + $content = $this->buildItemDefaultContent($selected_item); break; case 'edit': $content = $this->buildItemEditContent(); @@ -333,80 +320,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { return $page; } - public function buildNavigation( - PhabricatorProfileMenuItemConfiguration $selected_item = null) { - if ($this->navigation) { - return $this->navigation; - } - - $nav = id(new AphrontSideNavFilterView()) - ->setIsProfileMenu(true) - ->setBaseURI(new PhutilURI($this->getItemURI(''))); - - $menu_items = $this->getItems(); - - $filtered_items = array(); - foreach ($menu_items as $menu_item) { - if ($menu_item->isDisabled()) { - continue; - } - $filtered_items[] = $menu_item; - } - $filtered_groups = mgroup($filtered_items, 'getMenuItemKey'); - foreach ($filtered_groups as $group) { - $first_item = head($group); - $first_item->willBuildNavigationItems($group); - } - - $has_items = false; - foreach ($menu_items as $menu_item) { - if ($menu_item->isDisabled()) { - continue; - } - - $items = $menu_item->buildNavigationMenuItems(); - foreach ($items as $item) { - $this->validateNavigationMenuItem($item); - } - - // If the item produced only a single item which does not otherwise - // have a key, try to automatically assign it a reasonable key. This - // makes selecting the correct item simpler. - - if (count($items) == 1) { - $item = head($items); - if ($item->getKey() === null) { - $default_key = $menu_item->getDefaultMenuItemKey(); - $item->setKey($default_key); - } - } - - foreach ($items as $item) { - $nav->addMenuItem($item); - $has_items = true; - } - } - - if (!$has_items) { - // If the navigation menu has no items, add an empty label item to - // force it to render something. - $empty_item = id(new PHUIListItemView()) - ->setType(PHUIListItemView::TYPE_LABEL); - $nav->addMenuItem($empty_item); - } - - $nav->selectFilter(null); - - $navigation_items = $nav->getMenu()->getItems(); - $select_key = $this->pickHighlightedMenuItem( - $navigation_items, - $selected_item); - $nav->selectFilter($select_key); - - $this->navigation = $nav; - return $this->navigation; - } private function getItems() { if ($this->items === null) { @@ -715,7 +629,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { * * @return bool True if items may be pinned as default items. */ - protected function isMenuEnginePinnable() { + public function isMenuEnginePinnable() { return !$this->isMenuEnginePersonalizable(); } @@ -779,7 +693,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { $filtered_groups = mgroup($items, 'getMenuItemKey'); foreach ($filtered_groups as $group) { $first_item = head($group); - $first_item->willBuildNavigationItems($group); + $first_item->willGetMenuItemViewList($group); } // Users only need to be able to edit the object which this menu appears @@ -1153,8 +1067,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { } private function buildItemDefaultContent( - PhabricatorProfileMenuItemConfiguration $configuration, - array $items) { + PhabricatorProfileMenuItemConfiguration $configuration) { $controller = $this->getController(); $request = $controller->getRequest(); @@ -1220,6 +1133,17 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { ->setIsTailItem(true); } + public function getDefaultMenuItemConfiguration() { + $configs = $this->getItems(); + foreach ($configs as $config) { + if ($config->isDefault()) { + return $config; + } + } + + return null; + } + public function adjustDefault($key) { $controller = $this->getController(); $request = $controller->getRequest(); @@ -1340,157 +1264,74 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { pht('There are no menu items.')); } - private function pickDefaultItem(array $items) { - // Remove all the items which can not be the default item. - foreach ($items as $key => $item) { - if (!$item->canMakeDefault()) { - unset($items[$key]); - continue; - } + final public function newProfileMenuItemViewList() { + $items = $this->getItems(); + + // Throw away disabled items: they are not allowed to build any views for + // the menu. + foreach ($items as $key => $item) { if ($item->isDisabled()) { unset($items[$key]); continue; } } - // If this engine supports pinning items and a valid item is pinned, - // pick that item as the default. - if ($this->isMenuEnginePinnable()) { - foreach ($items as $key => $item) { - if ($item->isDefault()) { - return $item; - } - } + // Give each item group a callback so it can load data it needs to render + // views. + $groups = mgroup($items, 'getMenuItemKey'); + foreach ($groups as $group) { + $item = head($group); + $item->willGetMenuItemViewList($group); } - // If we have some other valid items, pick the first one as the default. - if ($items) { - return head($items); - } + $view_list = id(new PhabricatorProfileMenuItemViewList()) + ->setProfileMenuEngine($this); - return null; - } - - private function pickSelectedItem(array $items, $item_id, $is_view) { - if (strlen($item_id)) { - $item_id_int = (int)$item_id; - foreach ($items as $item) { - if ($item_id_int) { - if ((int)$item->getID() === $item_id_int) { - return $item; - } - } - - $builtin_key = $item->getBuiltinKey(); - if ($builtin_key === (string)$item_id) { - return $item; - } - } - - // Nothing matches the selected item ID, so we don't have a valid - // selection. - return null; - } - - if ($is_view) { - return $this->pickDefaultItem($items); - } - - return null; - } - - private function pickHighlightedMenuItem( - array $items, - PhabricatorProfileMenuItemConfiguration $selected_item = null) { - - assert_instances_of($items, 'PHUIListItemView'); - - $default_key = null; - if ($selected_item) { - $default_key = $selected_item->getDefaultMenuItemKey(); - } - - $controller = $this->getController(); - - // In some rare cases, when like building the "Favorites" menu on a - // 404 page, we may not have a controller. Just accept whatever default - // behavior we'd otherwise end up with. - if (!$controller) { - return $default_key; - } - - $request = $controller->getRequest(); - - // See T12949. If one of the menu items is a link to the same URI that - // the page was accessed with, we want to highlight that item. For example, - // this allows you to add links to a menu that apply filters to a - // workboard. - - $matches = array(); foreach ($items as $item) { - $href = $item->getHref(); - if ($this->isMatchForRequestURI($request, $href)) { - $matches[] = $item; + $views = $item->getMenuItemViewList(); + foreach ($views as $view) { + $view_list->addItemView($view); } } - foreach ($matches as $match) { - if ($match->getKey() === $default_key) { - return $default_key; + return $view_list; + } + + private function selectItem( + PhabricatorProfileMenuItemViewList $view_list, + $item_id, + $want_default) { + + // Figure out which view's content we're going to render. In most cases, + // the URI tells us. If we don't have an identifier in the URI, we'll + // render the default view instead if this is a workflow that falls back + // to default rendering. + + $selected_view = null; + if (strlen($item_id)) { + $item_views = $view_list->getViewsWithItemIdentifier($item_id); + if ($item_views) { + $selected_view = head($item_views); + } + } else { + if ($want_default) { + $default_views = $view_list->getDefaultViews(); + if ($default_views) { + $selected_view = head($default_views); + } } } - if ($matches) { - return head($matches)->getKey(); + if ($selected_view) { + $view_list->setSelectedView($selected_view); + $selected_item = $selected_view->getMenuItemConfiguration(); + } else { + $selected_item = null; } - return $default_key; + return $selected_item; } - private function isMatchForRequestURI(AphrontRequest $request, $item_uri) { - $request_uri = $request->getAbsoluteRequestURI(); - $item_uri = new PhutilURI($item_uri); - - // If the request URI and item URI don't have matching paths, they - // do not match. - if ($request_uri->getPath() !== $item_uri->getPath()) { - return false; - } - - // If the request URI and item URI don't have matching parameters, they - // also do not match. We're specifically trying to let "?filter=X" work - // on Workboards, among other use cases, so this is important. - $request_params = $request_uri->getQueryParamsAsPairList(); - $item_params = $item_uri->getQueryParamsAsPairList(); - if ($request_params !== $item_params) { - return false; - } - - // If the paths and parameters match, the item domain must be: empty; or - // match the request domain; or match the production domain. - - $request_domain = $request_uri->getDomain(); - - $production_uri = PhabricatorEnv::getProductionURI('/'); - $production_domain = id(new PhutilURI($production_uri)) - ->getDomain(); - - $allowed_domains = array( - '', - $request_domain, - $production_domain, - ); - $allowed_domains = array_fuse($allowed_domains); - - $item_domain = $item_uri->getDomain(); - $item_domain = (string)$item_domain; - - if (isset($allowed_domains[$item_domain])) { - return true; - } - - return false; - } } diff --git a/src/applications/search/engine/PhabricatorProfileMenuItemView.php b/src/applications/search/engine/PhabricatorProfileMenuItemView.php new file mode 100644 index 0000000000..44e0055a7d --- /dev/null +++ b/src/applications/search/engine/PhabricatorProfileMenuItemView.php @@ -0,0 +1,212 @@ +config = $config; + return $this; + } + + public function getMenuItemConfiguration() { + return $this->config; + } + + public function setURI($uri) { + $this->uri = $uri; + return $this; + } + + public function getURI() { + return $this->uri; + } + + public function setName($name) { + $this->name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setIcon($icon) { + $this->icon = $icon; + return $this; + } + + public function getIcon() { + return $this->icon; + } + + public function setDisabled($disabled) { + $this->disabled = $disabled; + return $this; + } + + public function getDisabled() { + return $this->disabled; + } + + public function setTooltip($tooltip) { + $this->tooltip = $tooltip; + return $this; + } + + public function getTooltip() { + return $this->tooltip; + } + + public function newAction($uri) { + $this->actions[] = $uri; + return null; + } + + public function newCount($count) { + $this->counts[] = $count; + return null; + } + + public function newProfileImage($src) { + $this->images[] = $src; + return null; + } + + public function newProgressBar($bar) { + $this->progressBars[] = $bar; + return null; + } + + public function setIsExternalLink($is_external) { + $this->isExternalLink = $is_external; + return $this; + } + + public function getIsExternalLink() { + return $this->isExternalLink; + } + + public function setIsLabel($is_label) { + return $this->setSpecialType('label'); + } + + public function getIsLabel() { + return $this->isSpecialType('label'); + } + + public function setIsDivider($is_divider) { + return $this->setSpecialType('divider'); + } + + public function getIsDivider() { + return $this->isSpecialType('divider'); + } + + private function setSpecialType($type) { + $this->specialType = $type; + return $this; + } + + private function isSpecialType($type) { + return ($this->specialType === $type); + } + + public function newListItemView() { + $view = id(new PHUIListItemView()) + ->setName($this->getName()); + + $uri = $this->getURI(); + if (strlen($uri)) { + if ($this->getIsExternalLink()) { + if (!PhabricatorEnv::isValidURIForLink($uri)) { + $uri = '#'; + } + $view->setRel('noreferrer'); + } + + $view->setHref($uri); + } + + $icon = $this->getIcon(); + if ($icon) { + $view->setIcon($icon); + } + + if ($this->getDisabled()) { + $view->setDisabled(true); + } + + if ($this->getIsLabel()) { + $view->setType(PHUIListItemView::TYPE_LABEL); + } + + if ($this->getIsDivider()) { + $view + ->setType(PHUIListItemView::TYPE_DIVIDER) + ->addClass('phui-divider'); + } + + if ($this->images) { + require_celerity_resource('people-picture-menu-item-css'); + foreach ($this->images as $image_src) { + $classes = array(); + $classes[] = 'people-menu-image'; + + if ($this->getDisabled()) { + $classes[] = 'phui-image-disabled'; + } + + $image = phutil_tag( + 'img', + array( + 'src' => $image_src, + 'class' => implode(' ', $classes), + )); + + $image = phutil_tag( + 'div', + array( + 'class' => 'people-menu-image-container', + ), + $image); + + $view->appendChild($image); + } + } + + foreach ($this->counts as $count) { + $view->appendChild( + phutil_tag( + 'span', + array( + 'class' => 'phui-list-item-count', + ), + $count)); + } + + foreach ($this->actions as $action) { + $view->setActionIcon('fa-pencil', $action); + } + + foreach ($this->progressBars as $bar) { + $view->appendChild($bar); + } + + return $view; + } + +} diff --git a/src/applications/search/engine/PhabricatorProfileMenuItemViewList.php b/src/applications/search/engine/PhabricatorProfileMenuItemViewList.php new file mode 100644 index 0000000000..4890657b4e --- /dev/null +++ b/src/applications/search/engine/PhabricatorProfileMenuItemViewList.php @@ -0,0 +1,278 @@ +engine = $engine; + return $this; + } + + public function getProfileMenuEngine() { + return $this->engine; + } + + public function addItemView(PhabricatorProfileMenuItemView $view) { + $this->views[] = $view; + return $this; + } + + public function getItemViews() { + return $this->views; + } + + public function setSelectedView(PhabricatorProfileMenuItemView $view) { + $found = false; + foreach ($this->getItemViews() as $item_view) { + if ($view === $item_view) { + $found = true; + break; + } + } + + if (!$found) { + throw new Exception( + pht( + 'Provided view is not one of the views in the list: you can only '. + 'select a view which appears in the list.')); + } + + $this->selectedView = $view; + + return $this; + } + + public function setSelectedViewWithItemIdentifier($identifier) { + $views = $this->getViewsWithItemIdentifier($identifier); + + if (!$views) { + throw new Exception( + pht( + 'No views match identifier "%s"!', + $identifier)); + } + + return $this->setSelectedView(head($views)); + } + + public function getViewsWithItemIdentifier($identifier) { + $views = $this->getItemViews(); + + if (!strlen($identifier)) { + return array(); + } + + if (ctype_digit($identifier)) { + $identifier_int = (int)$identifier; + } else { + $identifier_int = null; + } + + $identifier_str = (string)$identifier; + + $results = array(); + foreach ($views as $view) { + $config = $view->getMenuItemConfiguration(); + + if ($identifier_int !== null) { + $config_id = (int)$config->getID(); + if ($config_id === $identifier_int) { + $results[] = $view; + continue; + } + } + + if ($config->getBuiltinKey() === $identifier_str) { + $results[] = $view; + continue; + } + } + + return $results; + } + + public function getDefaultViews() { + $engine = $this->getProfileMenuEngine(); + $can_pin = $engine->isMenuEnginePinnable(); + + $views = $this->getItemViews(); + + // Remove all the views which were built by an item that can not be the + // default item. + foreach ($views as $key => $view) { + $config = $view->getMenuItemConfiguration(); + + if (!$config->canMakeDefault()) { + unset($views[$key]); + continue; + } + } + + // If this engine supports pinning items and we have candidate views from a + // valid pinned item, they are the default views. + if ($can_pin) { + $pinned = array(); + + foreach ($views as $key => $view) { + $config = $view->getMenuItemConfiguration(); + + if ($config->isDefault()) { + $pinned[] = $view; + continue; + } + } + + if ($pinned) { + return $pinned; + } + } + + // Return whatever remains that's still valid. + return $views; + } + + public function newNavigationView() { + $engine = $this->getProfileMenuEngine(); + + $base_uri = $engine->getItemURI(''); + $base_uri = new PhutilURI($base_uri); + + $navigation = id(new AphrontSideNavFilterView()) + ->setIsProfileMenu(true) + ->setBaseURI($base_uri); + + $views = $this->getItemViews(); + $selected_item = null; + $item_key = 0; + $items = array(); + foreach ($views as $view) { + $list_item = $view->newListItemView(); + + // Assign unique keys to the list items. These keys are purely internal. + $list_item->setKey(sprintf('item(%d)', $item_key++)); + + if ($this->selectedView) { + if ($this->selectedView === $view) { + $selected_item = $list_item; + } + } + + $navigation->addMenuItem($list_item); + $items[] = $list_item; + } + + if (!$views) { + // If the navigation menu has no items, add an empty label item to + // force it to render something. + $empty_item = id(new PHUIListItemView()) + ->setType(PHUIListItemView::TYPE_LABEL); + $navigation->addMenuItem($empty_item); + } + + $highlight_key = $this->getHighlightedItemKey( + $items, + $selected_item); + $navigation->selectFilter($highlight_key); + + return $navigation; + } + + private function getHighlightedItemKey( + array $items, + PHUIListItemView $selected_item = null) { + + assert_instances_of($items, 'PHUIListItemView'); + + $default_key = null; + if ($selected_item) { + $default_key = $selected_item->getKey(); + } + + $engine = $this->getProfileMenuEngine(); + $controller = $engine->getController(); + + // In some rare cases, when like building the "Favorites" menu on a + // 404 page, we may not have a controller. Just accept whatever default + // behavior we'd otherwise end up with. + if (!$controller) { + return $default_key; + } + + $request = $controller->getRequest(); + + // See T12949. If one of the menu items is a link to the same URI that + // the page was accessed with, we want to highlight that item. For example, + // this allows you to add links to a menu that apply filters to a + // workboard. + + $matches = array(); + foreach ($items as $item) { + $href = $item->getHref(); + if ($this->isMatchForRequestURI($request, $href)) { + $matches[] = $item; + } + } + + foreach ($matches as $match) { + if ($match->getKey() === $default_key) { + return $default_key; + } + } + + if ($matches) { + return head($matches)->getKey(); + } + + return $default_key; + } + + private function isMatchForRequestURI(AphrontRequest $request, $item_uri) { + $request_uri = $request->getAbsoluteRequestURI(); + $item_uri = new PhutilURI($item_uri); + + // If the request URI and item URI don't have matching paths, they + // do not match. + if ($request_uri->getPath() !== $item_uri->getPath()) { + return false; + } + + // If the request URI and item URI don't have matching parameters, they + // also do not match. We're specifically trying to let "?filter=X" work + // on Workboards, among other use cases, so this is important. + $request_params = $request_uri->getQueryParamsAsPairList(); + $item_params = $item_uri->getQueryParamsAsPairList(); + if ($request_params !== $item_params) { + return false; + } + + // If the paths and parameters match, the item domain must be: empty; or + // match the request domain; or match the production domain. + + $request_domain = $request_uri->getDomain(); + + $production_uri = PhabricatorEnv::getProductionURI('/'); + $production_domain = id(new PhutilURI($production_uri)) + ->getDomain(); + + $allowed_domains = array( + '', + $request_domain, + $production_domain, + ); + $allowed_domains = array_fuse($allowed_domains); + + $item_domain = $item_uri->getDomain(); + $item_domain = (string)$item_domain; + + if (isset($allowed_domains[$item_domain])) { + return true; + } + + return false; + } + +} diff --git a/src/applications/search/storage/PhabricatorProfileMenuItemConfiguration.php b/src/applications/search/storage/PhabricatorProfileMenuItemConfiguration.php index 4a23655d54..84736d0990 100644 --- a/src/applications/search/storage/PhabricatorProfileMenuItemConfiguration.php +++ b/src/applications/search/storage/PhabricatorProfileMenuItemConfiguration.php @@ -100,10 +100,6 @@ final class PhabricatorProfileMenuItemConfiguration return idx($this->menuItemProperties, $key, $default); } - public function buildNavigationMenuItems() { - return $this->getMenuItem()->buildNavigationMenuItems($this); - } - public function getMenuItemTypeName() { return $this->getMenuItem()->getMenuItemTypeName(); } @@ -124,8 +120,12 @@ final class PhabricatorProfileMenuItemConfiguration return $this->getMenuItem()->shouldEnableForObject($object); } - public function willBuildNavigationItems(array $items) { - return $this->getMenuItem()->willBuildNavigationItems($items); + public function willGetMenuItemViewList(array $items) { + return $this->getMenuItem()->willGetMenuItemViewList($items); + } + + public function getMenuItemViewList() { + return $this->getMenuItem()->getMenuItemViewList($this); } public function validateTransactions(array $map) {