Allow themes to change default icons sprite (#43087):

* name of the sprite must be icons.svg.
* if the icon doesn't exist in the theme sprite, it will fallback to default icon. 



git-svn-id: https://svn.redmine.org/redmine/trunk@24449 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Marius Balteanu
2026-02-22 03:23:41 +00:00
parent fe042f917f
commit 390f581a89
4 changed files with 161 additions and 1 deletions

View File

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

View File

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

View File

@@ -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('<symbol id="icon--edit"></symbol>')
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('<symbol id="icon--other"></symbol>')
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('<symbol id="icon--edit"></symbol>')
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{<svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/themes/test/icons(-123456)?\.svg#icon--edit"></use></svg>$}
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('<symbol id="icon--other"></symbol>')
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{<svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/icons(-\w+)?\.svg#icon--edit"></use></svg>$}
assert_match expected, sprite_icon('edit')
end
def test_sprite_icon_should_return_svg_with_custom_size
expected = %r{<svg class="s24 icon-svg" aria-hidden="true"><use href="/assets/icons-\w+.svg#icon--edit"></use></svg>$}
icon = sprite_icon('edit', size: '24')

View File

@@ -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('<svg><symbol id="icon--edit"></symbol><symbol id=\'icon--delete\'></symbol></svg>')
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('<symbol id="icon--edit"></symbol>')
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('<symbol id="icon--new"></symbol>')
assert_equal ['new'], theme.icons('icons')
end
end