From 81bbbddeb5d750eafddd18213242ceb0d768efe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C4=82LTEANU?= Date: Wed, 4 Feb 2026 10:10:47 +0700 Subject: [PATCH] Allow to change icons sprite from theme with a fallback mechanism to default sprite in case of the requested icon does not exist in the theme sprite (#43087). --- app/helpers/icons_helper.rb | 14 ++++- lib/redmine/themes.rb | 11 ++++ test/helpers/icons_helper_test.rb | 87 ++++++++++++++++++++++++++++ test/unit/lib/redmine/themes_test.rb | 50 ++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 6afb84537..f952f101c 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 7ef071f86..47afbeecf 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 -- 2.50.1 (Apple Git-155)