1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-22 14:52:41 +01:00

In ProfileMenu, put more structure between "stored/configured items" and "display items"

Summary:
Depends on D20356. Ref T13275. See also T12871 and T12949.

Currently, the whole "ProfileMenu" API operates around //stored// items. However, stored items are allowed to produce zero or more //display// items, and we sometimes want to highlight display item X but render stored item Y (as is the case with "Link" items pointing at `?filter=xyz` on Workboards).

For the most part, this either: doesn't work; or works by chance; or is kind of glued together with hope and prayer (as in D20353).

Put an actual structural layer in place between "stored/configured item" and "display item" that can link them together more clearly. Now:

  - The list of `ItemConfiguration` objects (stored/configured items) is used to build an `ItemViewList`.
  - This handles the selection/highlighting/default state, and knows which display items are related to which stored items.
  - When we're all done figuring out what we're going to select and what we're going to highlight, it pops out an actual View which can build the HTML.

This requires API changes which are not included in this change, see next change.

This doesn't really do anything on its own, but builds toward a more satisfying fix for T12871. I'd hoped to avoid doing this for now, but wasn't able to get a patch I felt good about for T12871 built without fixing this first.

Test Plan: See next change.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13275

Differential Revision: https://secure.phabricator.com/D20357
This commit is contained in:
epriestley 2019-03-31 11:48:44 -07:00
parent 971a272bf6
commit 950e9d085b
6 changed files with 568 additions and 233 deletions

View file

@ -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',

View file

@ -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());
}

View file

@ -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;
}
}

View file

@ -0,0 +1,212 @@
<?php
final class PhabricatorProfileMenuItemView
extends Phobject {
private $config;
private $uri;
private $name;
private $icon;
private $disabled;
private $tooltip;
private $actions = array();
private $counts = array();
private $images = array();
private $progressBars = array();
private $isExternalLink;
private $specialType;
public function setMenuItemConfiguration(
PhabricatorProfileMenuItemConfiguration $config) {
$this->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;
}
}

View file

@ -0,0 +1,278 @@
<?php
final class PhabricatorProfileMenuItemViewList
extends Phobject {
private $engine;
private $views = array();
private $selectedView;
public function setProfileMenuEngine(PhabricatorProfileMenuEngine $engine) {
$this->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;
}
}

View file

@ -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) {