diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 0dc341941..9a2ae2d98 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -18,11 +18,23 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module IconsHelper
+ include Redmine::Themes::Helper
+
DEFAULT_ICON_SIZE = "18"
DEFAULT_SPRITE = "icons"
+ def sprite_source(icon_name, sprite: DEFAULT_SPRITE, plugin: nil)
+ if plugin
+ "plugin_assets/#{plugin}/#{sprite}.svg"
+ elsif current_theme && current_theme.icons(sprite).include?(icon_name)
+ current_theme.image_path("#{sprite}.svg")
+ else
+ "#{sprite}.svg"
+ end
+ end
+
def sprite_icon(icon_name, label = nil, icon_only: false, size: DEFAULT_ICON_SIZE, style: :outline, css_class: nil, sprite: DEFAULT_SPRITE, plugin: nil, rtl: false)
- sprite = plugin ? "plugin_assets/#{plugin}/#{sprite}.svg" : "#{sprite}.svg"
+ sprite = sprite_source(icon_name, sprite: sprite, plugin: plugin)
svg_icon = svg_sprite_icon(icon_name, size: size, style: style, css_class: css_class, sprite: sprite, rtl: rtl)
diff --git a/lib/redmine/themes.rb b/lib/redmine/themes.rb
index 80e846550..601b8abd3 100644
--- a/lib/redmine/themes.rb
+++ b/lib/redmine/themes.rb
@@ -110,6 +110,17 @@ module Redmine
"themes/#{dir}/"
end
+ # Returns an array of icon names available in the given sprite
+ def icons(sprite)
+ asset = Rails.application.assets.load_path.find(image_path("#{sprite}.svg"))
+
+ return [] unless asset
+
+ ActionController::Base.cache_store.fetch("theme-icons/#{id}/#{sprite}/#{asset.digest}") do
+ asset.content.scan(/id=['"]icon--([^'"]+)['"]/).flatten
+ end
+ end
+
def asset_paths
base_dir = Pathname.new(path)
paths = base_dir.children.select do |child|
diff --git a/test/helpers/icons_helper_test.rb b/test/helpers/icons_helper_test.rb
index 527e8fc03..061e5bede 100644
--- a/test/helpers/icons_helper_test.rb
+++ b/test/helpers/icons_helper_test.rb
@@ -43,6 +43,93 @@ class IconsHelperTest < Redmine::HelperTest
assert_match expected, icon
end
+ def test_sprite_source_without_theme_should_return_default_sprite
+ stubs(:current_theme).returns(nil)
+
+ assert_equal "icons.svg", sprite_source('edit')
+ end
+
+ def test_sprite_source_with_theme_and_sprite_image_should_return_theme_path_if_icon_exists
+ theme = Redmine::Themes::Theme.new('/tmp/test')
+ theme.stubs(:id).returns('test')
+ theme.stubs(:image_path).with('icons.svg').returns('themes/test/icons.svg')
+
+ asset = mock('asset')
+ asset.stubs(:digest).returns('123456')
+ asset.stubs(:content).returns('')
+ asset.stubs(:digested_path).returns('themes/test/icons-123456.svg')
+
+ Rails.application.assets.load_path.stubs(:find).with('themes/test/icons.svg').returns(asset)
+ stubs(:current_theme).returns(theme)
+
+ assert_equal "themes/test/icons.svg", sprite_source('edit')
+ end
+
+ def test_sprite_source_with_theme_without_sprite_image_should_return_default_sprite
+ theme = Redmine::Themes::Theme.new('/tmp/test')
+ theme.stubs(:image_path).with('icons.svg').returns('themes/test/icons.svg')
+
+ Rails.application.assets.load_path.stubs(:find).with('themes/test/icons.svg').returns(nil)
+ stubs(:current_theme).returns(theme)
+
+ assert_equal "icons.svg", sprite_source('edit')
+ end
+
+ def test_sprite_source_with_theme_and_sprite_image_but_missing_icon_should_fallback_to_default_sprite
+ theme = Redmine::Themes::Theme.new('/tmp/test')
+ theme.stubs(:id).returns('test')
+ theme.stubs(:image_path).with('icons.svg').returns('themes/test/icons.svg')
+
+ asset = mock('asset')
+ asset.stubs(:digest).returns('123456')
+ asset.stubs(:content).returns('')
+ asset.stubs(:digested_path).returns('themes/test/icons-123456.svg')
+
+ Rails.application.assets.load_path.stubs(:find).with('themes/test/icons.svg').returns(asset)
+ stubs(:current_theme).returns(theme)
+
+ assert_equal "icons.svg", sprite_source('edit')
+ end
+
+ def test_sprite_icon_with_theme_override_should_use_theme_sprite
+ theme = Redmine::Themes::Theme.new('/tmp/test')
+ theme.stubs(:id).returns('test')
+ theme.stubs(:image_path).with('icons.svg').returns('themes/test/icons.svg')
+
+ asset = mock('asset')
+ asset.stubs(:digest).returns('123456')
+ asset.stubs(:content).returns('')
+ asset.stubs(:digested_path).returns('themes/test/icons-123456.svg')
+
+ Rails.application.assets.load_path.stubs(:find).with('themes/test/icons.svg').returns(asset)
+ stubs(:current_theme).returns(theme)
+
+ expected = %r{$}
+ assert_match expected, sprite_icon('edit')
+ end
+
+ def test_sprite_icon_with_theme_missing_icon_should_fallback_to_default_sprite
+ theme = Redmine::Themes::Theme.new('/tmp/test')
+ theme.stubs(:id).returns('test')
+ theme.stubs(:image_path).with('icons.svg').returns('themes/test/icons.svg')
+
+ asset = mock('asset')
+ asset.stubs(:digest).returns('123456')
+ asset.stubs(:content).returns('')
+ asset.stubs(:digested_path).returns('themes/test/icons-123456.svg')
+
+ Rails.application.assets.load_path.stubs(:find).with('themes/test/icons.svg').returns(asset)
+
+ default_asset = mock('asset')
+ default_asset.stubs(:digested_path).returns('icons-default.svg')
+ Rails.application.assets.load_path.stubs(:find).with('icons.svg').returns(default_asset)
+
+ stubs(:current_theme).returns(theme)
+
+ expected = %r{$}
+ assert_match expected, sprite_icon('edit')
+ end
+
def test_sprite_icon_should_return_svg_with_custom_size
expected = %r{$}
icon = sprite_icon('edit', size: '24')
diff --git a/test/unit/lib/redmine/themes_test.rb b/test/unit/lib/redmine/themes_test.rb
index 139dded65..f24df6ea4 100644
--- a/test/unit/lib/redmine/themes_test.rb
+++ b/test/unit/lib/redmine/themes_test.rb
@@ -59,4 +59,54 @@ class Redmine::ThemesTest < ActiveSupport::TestCase
ensure
Redmine::Themes.rescan
end
+
+ def test_icons_should_return_available_icons
+ theme = Redmine::Themes::Theme.new('/tmp/test')
+ theme.stubs(:image_path).with('icons.svg').returns('themes/test/icons.svg')
+
+ asset = mock('asset')
+ asset.stubs(:content).returns('')
+ asset.stubs(:digest).returns('123456')
+
+ Rails.application.assets.load_path.stubs(:find).with('themes/test/icons.svg').returns(asset)
+
+ assert_equal ['edit', 'delete'], theme.icons('icons')
+ end
+
+ def test_icons_should_return_empty_array_if_asset_missing
+ theme = Redmine::Themes::Theme.new('/tmp/test')
+ theme.stubs(:image_path).with('icons.svg').returns('themes/test/icons.svg')
+
+ Rails.application.assets.load_path.stubs(:find).with('themes/test/icons.svg').returns(nil)
+
+ assert_equal [], theme.icons('icons')
+ end
+
+ def test_icons_should_be_cached
+ theme = Redmine::Themes::Theme.new('/tmp/test')
+ theme.stubs(:id).returns('test')
+ theme.stubs(:image_path).with('icons.svg').returns('themes/test/icons.svg')
+
+ asset = mock('asset')
+ asset.stubs(:content).returns('')
+ asset.stubs(:digest).returns('123456')
+
+ Rails.application.assets.load_path.stubs(:find).with('themes/test/icons.svg').returns(asset)
+
+ # Use a memory store for this test since the test environment uses null_store
+ memory_store = ActiveSupport::Cache.lookup_store(:memory_store)
+ ActionController::Base.stubs(:cache_store).returns(memory_store)
+
+ # First call - cache miss
+ assert_equal ['edit'], theme.icons('icons')
+
+ # Second call - verify it's in the cache
+ cache_key = "theme-icons/test/icons/123456"
+ assert_equal ['edit'], memory_store.read(cache_key)
+
+ # If digest changes, it should miss cache
+ asset.stubs(:digest).returns('789')
+ asset.stubs(:content).returns('')
+ assert_equal ['new'], theme.icons('icons')
+ end
end