Skip to content

Commit b506c2e

Browse files
committed
fix MenuItem label sorting of procs
@macfanatic's changes in 452956e changed label procs to be evaluated in the view context, but this broke `MenuItem` sorting. This commit pushes the logic back down into `MenuItem`, and provides a way to pass in an optional context in which to evaluate symbols and procs. + forces `MenuItem`-specific logic out of `TabbedNavigation` + `items` now handles both sorting and display decisions. Call `children` if you want them all + `add` was refactored for clarity + integrates fixes from activeadmin#1988
1 parent 9b44a50 commit b506c2e

File tree

13 files changed

+230
-287
lines changed

13 files changed

+230
-287
lines changed

features/menu.feature

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@ Feature: Menu
3737
Then I should see a menu item for "Custom Menu"
3838
When I follow "Custom Menu"
3939
Then I should be on the admin dashboard page
40+
41+
Scenario: Adding a resource as a sub menu item
42+
Given a configuration of:
43+
"""
44+
ActiveAdmin.register User
45+
ActiveAdmin.register Post do
46+
menu :parent => 'User'
47+
end
48+
"""
49+
When I am on the dashboard
50+
Then I should see a menu item for "Users"
51+
When I follow "Users"
52+
Then the "Users" tab should be selected
53+
And I should see a nested menu item for "Posts"

features/step_definitions/menu_steps.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@
55
Then /^I should not see a menu item for "([^"]*)"$/ do |name|
66
page.should_not have_css('#tabs li a', :text => name)
77
end
8+
9+
Then /^I should see a nested menu item for "([^"]*)"$/ do |name|
10+
page.should have_css('#tabs > li > ul > li > a', :text => name)
11+
end

lib/active_admin/menu.rb

Lines changed: 57 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ module ActiveAdmin
55
# To build a new menu:
66
#
77
# menu = Menu.new do |m|
8-
# m.add :label => "Dashboard", :url => "/"
9-
# m.add :label => "Admin", :url => "/admin"
8+
# m.add label: 'Dashboard', url: '/'
9+
# m.add label: 'Users', url: '/users'
1010
# end
1111
#
1212
# If you're interested in configuring a menu item, take a look at the
@@ -15,80 +15,91 @@ module ActiveAdmin
1515
class Menu
1616

1717
def initialize
18+
super # MenuNode
1819
yield(self) if block_given?
1920
end
2021

2122
module MenuNode
23+
def initialize
24+
@children = {}
25+
end
26+
27+
def [](id)
28+
@children[normalize_id(id)]
29+
end
30+
def []=(id, child)
31+
@children[normalize_id(id)] = child
32+
end
2233

23-
# Add a new MenuItem to the menu
34+
# Recursively builds any given menu items. There are two syntaxes supported,
35+
# as shown in the below examples. Both create an identical menu structure.
2436
#
25-
# Example:
37+
# Example 1:
2638
# menu = Menu.new
27-
# menu.add :label => "Dashboard" do |dash|
28-
# dash.add :label => "My Child Dashboard"
39+
# menu.add label: 'Dashboard' do |dash|
40+
# dash.add label: 'My Child Dashboard'
2941
# end
3042
#
31-
# @param [Hash] menu_items Add as many menu items as you pass in
32-
def add(item_options)
33-
parent_label = item_options[:parent]
34-
35-
if parent_label
36-
child_options = item_options.except(:parent)
37-
menu_item = add_with_parent(parent_label, child_options)
43+
# Example 2:
44+
# menu = Menu.new
45+
# menu.add label: 'Dashboard'
46+
# menu.add parent: 'Dashboard', label: 'My Child Dashboard'
47+
#
48+
def add(options)
49+
item = if parent = options.delete(:parent)
50+
(self[parent] || add(:label => parent)).add options
3851
else
39-
menu_item = add_without_parent(item_options)
52+
_add options.merge :parent => self
4053
end
4154

42-
yield(menu_item) if block_given?
55+
yield(item) if block_given?
4356

44-
menu_item
45-
end
46-
47-
def [](id)
48-
children.fetch(normalize_id(id))
57+
item
4958
end
5059

60+
# Whether any children match the given item.
5161
def include?(item)
52-
items.include?(item)
62+
@children.values.include? item
5363
end
5464

55-
# @return Sorted [Array] of [MenuItem]
56-
def items
57-
children.values.sort
65+
# Used in the UI to visually distinguish which menu item is selected.
66+
def current?(item)
67+
self == item || include?(item)
5868
end
5969

60-
private
61-
62-
def add_without_parent(item_options)
63-
menu_item = ActiveAdmin::MenuItem.new(item_options, self)
64-
children[menu_item.id] = menu_item
65-
end
66-
67-
def add_with_parent(parent, item_options)
68-
parent_id = normalize_id(parent)
69-
parent = children.fetch(parent_id){ add(:label => parent) }
70-
71-
parent.add(item_options)
70+
# Returns sorted array of menu items that should be displayed in this context.
71+
# Sorts by priority first, then alphabetically by label if needed.
72+
def items(context = nil)
73+
@children.values.select{ |i| i.display?(context) }.sort do |a,b|
74+
result = a.priority <=> b.priority
75+
result = a.label(context) <=> b.label(context) if result == 0
76+
result
77+
end
7278
end
7379

74-
def children
75-
@children ||= {}
80+
attr_reader :children
81+
private
82+
attr_writer :children
83+
84+
# The method that actually adds new menu items. Called by the public method.
85+
# If this ID is already taken, transfer the children of the existing item to the new item.
86+
def _add(options)
87+
item = ActiveAdmin::MenuItem.new(options)
88+
item.send :children=, self[item.id].children if self[item.id]
89+
self[item.id] = item
7690
end
7791

78-
def normalize_id(string)
79-
case string
80-
when Proc
81-
string
92+
def normalize_id(id)
93+
case id
94+
when String, Symbol
95+
id.to_s.downcase.gsub ' ', '_'
8296
else
83-
string.to_s.downcase.gsub(" ", "_")
97+
raise TypeError, "#{id.class} isn't supported as a Menu ID"
8498
end
8599
end
86-
87100
end
88101

89102
include MenuNode
90103

91104
end
92-
93-
94105
end

lib/active_admin/menu_item.rb

Lines changed: 61 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,96 @@
1-
module ActiveAdmin
1+
require 'active_admin/view_helpers/method_or_proc_helper'
22

3+
module ActiveAdmin
34
class MenuItem
45
include Menu::MenuNode
6+
include MethodOrProcHelper
57

6-
attr_accessor :id, :label, :url, :priority, :display_if_block, :html_options
7-
attr_reader :parent
8+
attr_reader :html_options, :parent, :priority
89

9-
# Build a new menu item
10+
# Builds a new menu item
1011
#
1112
# @param [Hash] options The options for the menu
1213
#
13-
# @option options [String, Proc] :label
14-
# The label to display for this menu item. It can either be a String or a
15-
# Proc. If the option is Proc, it is called each time the label is requested.
14+
# @option options [String, Symbol, Proc] :label
15+
# The label to display for this menu item.
16+
# Default: Titleized Resource Name
1617
#
1718
# @option options [String] :id
18-
# A custom id to reference this menu item with. If empty an id is automatically
19-
# generated for you.
19+
# A custom id to reference this menu item with.
20+
# Default: underscored_resource_name
2021
#
21-
# @option options [String, Symbol] :url
22-
# A string or symbol representing the url for this item. If it's a symbol, the
23-
# view will automatically call the method for you.
22+
# @option options [String, Symbol, Proc] :url
23+
# The URL this item will link to.
2424
#
2525
# @option options [Integer] :priority
26-
# MenuItems are sorted by priority then by label. The lower the priority, the
27-
# earlier in the menu the item will be displayed.
26+
# The lower the priority, the earlier in the menu the item will be displayed.
2827
# Default: 10
2928
#
30-
# @option options [Proc] :if
31-
# A block for the view to call to decide if this menu item should be displayed.
32-
# The block should return true of false
29+
# @option options [Symbol, Proc] :if
30+
# This decides whether the menu item will be displayed. Evaluated on each request.
3331
#
3432
# @option options [Hash] :html_options
3533
# A hash of options to pass to `link_to` when rendering the item
3634
#
37-
# @param [ActiveAdmin::MenuItem] parent The parent menu item of this item
35+
# @option [ActiveAdmin::MenuItem] :parent
36+
# This menu item's parent. It will be displayed nested below its parent.
3837
#
39-
def initialize(options = {}, parent = nil)
40-
@label = options[:label]
41-
@id = normalize_id(options[:id] || label)
42-
@url = options[:url] || "#"
43-
@priority = options[:priority] || 10
44-
@html_options = options[:html_options] || {}
45-
@parent = parent
46-
47-
@display_if_block = options[:if]
38+
# NOTE: for :label, :url, and :if
39+
# These options are evaluated in the view context at render time. Symbols are called
40+
# as methods on `self`, and Procs are exec'd within `self`.
41+
# Here are some examples of what you can do:
42+
#
43+
# menu if: :admin?
44+
# menu url: :new_book_path
45+
# menu url: :awesome_helper_you_defined
46+
# menu label: ->{ User.some_method }
47+
# menu label: ->{ I18n.t 'menus.user' }
48+
#
49+
def initialize(options = {})
50+
super() # MenuNode
51+
@label = options[:label]
52+
@dirty_id = options[:id] || options[:label]
53+
@url = options[:url] || '#'
54+
@priority = options[:priority] || 10
55+
@html_options = options[:html_options] || {}
56+
@should_display = options[:if] || proc{true}
57+
@parent = options[:parent]
4858

4959
yield(self) if block_given? # Builder style syntax
5060
end
5161

52-
def parent?
53-
!parent.nil?
62+
def id
63+
@id ||= normalize_id @dirty_id
5464
end
5565

56-
def dom_id
57-
case id
58-
when Proc
59-
id
60-
else
61-
id.to_s.gsub( " ", '_' ).gsub( /[^a-z0-9_]/, '' )
62-
end
66+
def label(context = nil)
67+
render_in_context context, @label
6368
end
6469

65-
# Returns an array of the ancestory of this menu item
66-
# The first item is the immediate parent fo the item
67-
def ancestors
68-
return [] unless parent?
69-
[parent, parent.ancestors].flatten
70+
def url(context = nil)
71+
render_in_context context, @url
7072
end
7173

72-
def <=>(other)
73-
result = priority <=> other.priority
74-
result = label.to_s <=> other.label.to_s if result == 0
75-
result
74+
# Don't display if the :if option passed says so
75+
# Don't display if the link isn't real, we have children, and none of the children are being displayed.
76+
def display?(context = nil)
77+
return false unless render_in_context(context, @should_display)
78+
return false if !real_url?(context) && @children.any? && !items(context).any?
79+
true
7680
end
7781

78-
# Returns the display if block. If the block was not explicitly defined
79-
# a default block always returning true will be returned.
80-
def display_if_block
81-
@display_if_block || lambda { |_| true }
82+
# Returns an array of the ancestory of this menu item.
83+
# The first item is the immediate parent of the item.
84+
def ancestors
85+
parent ? [parent, parent.ancestors].flatten : []
86+
end
87+
88+
private
89+
90+
# URL is not nil, empty, or '#'
91+
def real_url?(context = nil)
92+
url = url context
93+
url.present? && url != '#'
8294
end
8395

8496
end

lib/active_admin/resource/menu.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ def menu_item_options
1919
@menu_item_options ||= default_menu_options
2020
end
2121

22-
# The default menu options to pass through to MenuItem.new
2322
def default_menu_options
23+
# These local variables are accessible to the procs.
2424
menu_resource_class = respond_to?(:resource_class) ? resource_class : self
25-
the_resource = self # Make it available in the proc
25+
resource = self
2626
{
27-
:id => resource_name.plural,
28-
:label => proc{ plural_resource_label },
29-
:url => proc{ the_resource.route_collection_path(params) },
30-
:if => proc { authorized?(:read, menu_resource_class) }
27+
:id => resource_name.plural,
28+
:label => proc{ resource.plural_resource_label },
29+
:url => proc{ resource.route_collection_path(params) },
30+
:if => proc{ authorized?(:read, menu_resource_class) }
3131
}
3232
end
3333

lib/active_admin/view_helpers/method_or_proc_helper.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,19 @@ def render_or_call_method_or_proc_on(obj, string_symbol_or_proc, options = {})
7575
string_symbol_or_proc
7676
end
7777
end
78+
79+
# This method is different from the others in that it calls `instance_eval` on the reciever,
80+
# passing it the proc. This evaluates the proc in the context of the reciever, thus changing
81+
# what `self` means inside the proc.
82+
def render_in_context(context, obj)
83+
context ||= self # default to `self`
84+
case obj
85+
when Proc
86+
context.instance_exec &obj
87+
when Symbol
88+
context.send obj
89+
else
90+
obj
91+
end
92+
end
7893
end

0 commit comments

Comments
 (0)