Merge pull request #230 from opf/feature/rails3-themes-refactoring

Refactoring of Themes Backend
pull/241/merge
meeee 11 years ago
commit 456ce7ae47
  1. 4
      app/helpers/application_helper.rb
  2. 2
      app/views/settings/_display.html.erb
  3. 19
      lib/open_project/themes.rb
  4. 20
      lib/open_project/themes/default_theme.rb
  5. 112
      lib/open_project/themes/theme.rb
  6. 62
      lib/open_project/themes/theme_finder.rb
  7. 10
      lib/open_project/themes/view_helpers.rb
  8. 2
      lib/redmine.rb
  9. 148
      lib/redmine/themes/theme.rb
  10. 16
      spec/lib/open_project/themes/default_theme_spec.rb
  11. 137
      spec/lib/open_project/themes/theme_finder_spec.rb
  12. 117
      spec/lib/open_project/themes/theme_spec.rb
  13. 2
      spec/lib/open_project/themes/view_helpers_spec.rb
  14. 2
      spec/lib/open_project/themes_spec.rb
  15. 2
      test/integration/lib/redmine/themes_test.rb

@ -468,9 +468,9 @@ module ApplicationHelper
# Returns the theme, controller name, and action as css classes for the
# HTML body.
def body_css_classes
theme = Redmine::Themes.theme(Setting.ui_theme)
theme = OpenProject::Themes.theme(Setting.ui_theme)
css = ['theme-' + theme.name.to_s]
css = ['theme-' + theme.identifier.to_s]
if params[:controller] && params[:action]
css << 'controller-' + params[:controller]

@ -13,7 +13,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= form_tag({:action => 'edit', :tab => 'display'}) do %>
<div class="box tabular settings">
<p><%= setting_select :ui_theme, Redmine::Themes.collect { |theme| [theme.name, theme.identifier] }, :label => :label_theme %></p>
<p><%= setting_select :ui_theme, OpenProject::Themes.collect { |theme| [theme.name, theme.identifier] }, :label => :label_theme %></p>
<p id="setting_available_languages"><%= setting_multiselect :available_languages, all_lang_options_for_select(false) %></p>

@ -9,16 +9,19 @@
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'redmine/themes/theme'
require 'redmine/themes/default_theme' # always load the default theme
module Redmine
require 'open_project/themes/theme'
require 'open_project/themes/theme_finder'
require 'open_project/themes/default_theme' # always load the default theme
module OpenProject
module Themes
class << self
delegate :new_theme, :themes, :all, to: Theme
delegate :new_theme, to: Theme
delegate :all, :themes, :clear_themes, to: ThemeFinder
def theme(identifier)
Theme.fetch(identifier) { default_theme }
ThemeFinder.fetch(identifier) { default_theme }
end
def default_theme
@ -33,10 +36,6 @@ module Redmine
Setting.ui_theme.to_s.to_sym.presence
end
def clear_themes
Theme.clear
end
include Enumerable
delegate :each, to: :themes
end
@ -44,6 +43,6 @@ module Redmine
end
# add view helpers to application
require 'redmine/themes/view_helpers'
require 'open_project/themes/view_helpers'
ActiveSupport.run_load_hooks(:themes)

@ -10,27 +10,19 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'redmine/themes/theme'
require 'open_project/themes/theme'
module Redmine
module OpenProject
module Themes
class DefaultTheme < Redmine::Themes::Theme
def identifier
:default
end
class DefaultTheme < OpenProject::Themes::Theme
def name
'Default'
'OpenProject'
end
def assets_path
@assets_path ||= Rails.root.join('app/assets').to_s
end
def stylesheet_manifest
'default.css'
end
def assets_prefix
''
end
@ -39,10 +31,6 @@ module Redmine
[]
end
def default?
true
end
def image_overridden?(source)
false
end

@ -0,0 +1,112 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
#
# Copyright (C) 2012-2013 the OpenProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'singleton'
require 'open_project/themes/theme_finder'
module OpenProject
module Themes
class Theme
class SubclassResponsibility < StandardError
end
class << self
def inherited(subclass)
# make all theme classes singletons
subclass.send :include, Singleton
# register the theme with the ThemeFinder
ThemeFinder.register_theme(subclass.instance)
end
def new_theme(identifier = nil)
theme = Class.new(self).instance
theme.identifier = identifier if identifier
theme
end
def abstract!
@abstract = true
# tell ThemeFinder to forget the theme
ThemeFinder.forget_theme(instance)
# undefine methods responsible for creating instances
singleton_class.send :remove_method, *[:new, :allocate, :instance]
end
def abstract?
@abstract
end
end
# 'OpenProject::Themes::GoofyTheme' => :'goofy'
def identifier
@identifier ||= self.class.to_s.gsub(/Theme$/, '').demodulize.underscore.dasherize.to_sym
end
attr_writer :identifier
# 'OpenProject::Themes::GoofyTheme' => 'Goofy'
def name
@name ||= self.class.to_s.gsub(/Theme$/, '').demodulize.titleize
end
def stylesheet_manifest
"#{identifier}.css"
end
def assets_prefix
identifier.to_s
end
def assets_path
raise SubclassResponsibility, "override this method to point to your theme's assets folder"
end
def overridden_images_path
@overridden_images_path ||= File.join(assets_path, 'images', assets_prefix)
end
def overridden_images
@overridden_images ||= \
begin
Dir.chdir(overridden_images_path) { Dir.glob('**/*') }
rescue Errno::ENOENT # overridden_images_path missing
[]
end.to_set
end
def image_overridden?(source)
source.in?(overridden_images)
end
URI_REGEXP = %r{^[-a-z]+://|^(?:cid|data):|^//}
def path_to_image(source)
return source if source =~ URI_REGEXP
return source if source[0] == ?/
if image_overridden?(source)
File.join(assets_prefix, source)
else
source
end
end
include Comparable
delegate :'<=>', :abstract?, to: :'self.class'
include Singleton
abstract!
end
end
end

@ -0,0 +1,62 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
#
# Copyright (C) 2012-2013 the OpenProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module OpenProject
module Themes
module ThemeFinder
class << self
def themes
@_themes ||= []
end
alias_method :all, :themes
def registered_themes
@_registered_themes ||= \
themes.each_with_object({}) do |theme, themes|
themes[theme.identifier] = theme
end
end
delegate :fetch, to: :registered_themes
def register_theme(theme)
self.themes << theme
clear_cache
# register the theme's stylesheet manifest with rails' asset pipeline
# we need to wrap the call to #stylesheet_manifest in a Proc,
# because when this code is executed the theme instance (theme) hasn't had
# a chance to override the method yet
Rails.application.config.assets.precompile << Proc.new {
theme.stylesheet_manifest unless theme.abstract?
}
end
def forget_theme(theme)
themes.delete(theme)
clear_cache
end
def clear_themes
themes.clear
clear_cache
end
def clear_cache
@_registered_themes = nil
end
include Enumerable
delegate :each, to: :themes
end
end
end
end

@ -10,16 +10,16 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'redmine/themes'
require 'open_project/themes'
module Redmine
module OpenProject
module Themes
module ViewHelpers
# returns the theme currently configured by the settings
# if none is configured or one cannot be found it returns the default theme
# which means this helper always returns a Redmine::Themes::Theme subclass
# which means this helper always returns a OpenProject::Themes::Theme subclass
def current_theme
Redmine::Themes.current_theme
OpenProject::Themes.current_theme
end
# overrides image_tag defined in ActionView::Helpers::AssetTagHelpers (Rails 4)
@ -43,5 +43,5 @@ end
module ApplicationHelper
# including a module is way better than defining methods directly in the application helper's module
# it plays nicely with inheritence and it will show up in ApplicationHelper.ancestors list
include Redmine::Themes::ViewHelpers
include OpenProject::Themes::ViewHelpers
end

@ -17,7 +17,7 @@ require 'redmine/search'
require 'redmine/custom_field_format'
require 'redmine/mime_type'
require 'redmine/core_ext'
require 'redmine/themes'
require 'open_project/themes'
require 'redmine/hook'
require 'redmine/plugin'
require 'redmine/notifiable'

@ -1,148 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
#
# Copyright (C) 2012-2013 the OpenProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'singleton'
require 'active_support/descendants_tracker'
module Redmine
module Themes
class Theme
class SubclassResponsibility < StandardError
end
class << self
include ActiveSupport::DescendantsTracker
def inherited(base)
super # call to ActiveSupport::DescendantsTracker
base.send :include, Singleton # make all theme classes singletons
clear_cache # clear the themes cache
# register the theme's stylesheet manifest with rails' asset pipeline
# we need to wrap the call to #stylesheet_manifest in a Proc,
# because when this code is executed the theme class (base) hasn't had
# a chance to override the method yet
Rails.application.config.assets.precompile << Proc.new {
base.instance.stylesheet_manifest unless base.abstract?
}
end
def new_theme(identifier = nil)
theme = Class.new(self).instance
theme.identifier = identifier
theme
end
def themes
@_themes ||= (descendants - abstract_themes).map(&:instance)
end
alias_method :all, :themes
def registered_themes
@_registered_themes ||= \
themes.each_with_object(Hash.new) do |theme, themes|
themes[theme.identifier] = theme
end
end
delegate :fetch, to: :registered_themes
def clear
direct_descendants.clear && clear_cache
end
def clear_cache
@_themes = @_registered_themes = nil
end
def abstract!
Theme.abstract_themes << self
# undefine methods responsible for creating instances
singleton_class.send :remove_method, *[:new, :allocate, :instance]
end
def abstract?
self.in?(Theme.abstract_themes)
end
def abstract_themes
@_abstract_themes ||= Array.new
end
include Enumerable
delegate :each, to: :themes
end
# "Redmine::Themes::AwesomeTheme".demodulize.underscore.dasherize.to_sym => :"awesome-theme"
def identifier
@identifier ||= self.class.to_s.demodulize.underscore.dasherize.to_sym
end
attr_writer :identifier
# "Redmine::Themes::AwesomeTheme".demodulize.titleize => "Awesome Theme"
def name
@name ||= self.class.to_s.demodulize.titleize
end
def stylesheet_manifest
"#{identifier}.css"
end
def assets_prefix
identifier.to_s
end
def assets_path
raise SubclassResponsibility, "override this method to point to your theme's assets folder"
end
def overridden_images_path
@overridden_images_path ||= File.join(assets_path, 'images', assets_prefix)
end
def overridden_images
@overridden_images ||= \
begin
Dir.chdir(overridden_images_path) { Dir.glob('**/*') }
rescue Errno::ENOENT # overridden_images_path missing
[]
end.to_set
end
def image_overridden?(source)
source.in?(overridden_images)
end
URI_REGEXP = %r{^[-a-z]+://|^(?:cid|data):|^//}
def path_to_image(source)
return source if source =~ URI_REGEXP
return source if source[0] == ?/
if image_overridden?(source)
File.join(assets_prefix, source)
else
source
end
end
def default?
false
end
include Comparable
delegate :'<=>', to: :'self.class'
include Singleton
abstract!
end
end
end

@ -11,13 +11,19 @@
require 'spec_helper'
module Redmine
module OpenProject
module Themes
describe DefaultTheme do
let(:theme) { DefaultTheme.instance }
describe '#name' do
it 'is called OpenProject' do
expect(theme.name).to eq 'OpenProject'
end
end
describe '#stylesheet_manifest' do
it 'it is default with a css extension' do
it 'is default with a css extension' do
expect(theme.stylesheet_manifest).to eq 'default.css'
end
end
@ -81,12 +87,6 @@ module Redmine
expect(theme.image_overridden?('theme_spec.rb')).to be_false
end
end
describe '#default?' do
it "returns true" do
expect(DefaultTheme.instance).to be_default
end
end
end
describe ViewHelpers do

@ -0,0 +1,137 @@
#-- copyright
# OpenProject is a project management system.
#
# Copyright (C) 2012-2013 the OpenProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
module OpenProject
module Themes
describe ThemeFinder do
before { ThemeFinder.clear_themes }
describe '.themes' do
it "returns all instances of descendants of themes" do
theme = Theme.new_theme
expect(ThemeFinder.themes).to include theme
end
# the before filter above removes the default theme as well. to test
# the correct behaviour we just spec that the default theme class
# was loaded (by looking through all subclasses of BasicObject)
it "always includes the default theme" do
loaded_classes = Object.descendants
expect(loaded_classes).to include Themes::DefaultTheme
end
# test through the theme instances classes because
# an abstract theme can't have an instance
it "filters out themes marked as abstract" do
theme_class = Class.new(Theme) { abstract! }
theme_classes = ThemeFinder.themes.map(&:class)
expect(theme_classes).to_not include theme_class
end
it "subclasses of abstract themes aren't abstract by default" do
abstract_theme_class = Class.new(Theme) { abstract! }
theme = Class.new(abstract_theme_class).instance
expect(ThemeFinder.themes).to include theme
end
end
describe '.registered_themes' do
it "returns a hash of themes with their identifiers as keys" do
theme = Theme.new_theme(:new_theme)
expect(ThemeFinder.registered_themes).to include :new_theme => theme
end
end
describe '.register_theme' do
it "remembers whatever is passed in (this is called by #inherited hook)" do
theme = stub # do not invoke inherited callback
ThemeFinder.register_theme(theme)
expect(ThemeFinder.themes).to include theme
end
# TODO: clean me up
it "registers the theme's stylesheet manifest for precompilation" do
Class.new(Theme) { def stylesheet_manifest; 'stylesheet_path.css'; end }
# TODO: gives an error on the whole list
# TODO: remove themes from the list, when clear_themes is called
precompile_list = Rails.application.config.assets.precompile
precompile_list = Array(precompile_list.last)
precompile_list.map! { |element| element.respond_to?(:call) ? element.call : element }
expect(precompile_list).to include 'stylesheet_path.css'
end
it "clears the cache successfully" do
ThemeFinder.registered_themes # fill the cache
theme = Theme.new_theme(:new_theme)
expect(ThemeFinder.registered_themes).to include :new_theme => theme
end
end
describe '.forget_theme' do
it "removes the theme from the themes list" do
theme = Theme.new_theme(:new_theme)
ThemeFinder.forget_theme(theme)
expect(ThemeFinder.themes).to_not include theme
end
end
describe '.clear_cache' do
it "removes the theme from the registered themes list and clears the cache" do
theme = Theme.new_theme(:new_theme)
ThemeFinder.registered_themes # fill the cache
ThemeFinder.forget_theme(theme)
expect(ThemeFinder.registered_themes).to_not include :new_theme => theme
end
end
describe '.abstract!' do
it "abstract themes won't show up in the themes llist" do
abstract_theme_class = Class.new(Theme) { abstract! }
theme_classes = ThemeFinder.themes.map(&:class)
expect(theme_classes).to_not include abstract_theme_class
end
it "the basic theme class is abstract" do
theme_classes = ThemeFinder.themes.map(&:class)
expect(theme_classes).to_not include Theme
end
end
describe '.clear_themes' do
it "it wipes out all registered themes" do
theme_class = Class.new(Theme)
ThemeFinder.clear_themes
expect(ThemeFinder.themes).to be_empty
end
it "clears the registered themes cache" do
theme = Theme.new_theme(:new_theme)
ThemeFinder.registered_themes # fill the cache
ThemeFinder.clear_themes
expect(ThemeFinder.registered_themes).to_not include :new_theme => theme
end
end
describe '.each' do
it "iterates over all themes" do
Theme.new_theme(:new_theme)
themes = []
ThemeFinder.each { |theme| themes << theme.identifier }
expect(themes).to eq [:new_theme]
end
end
end
end
end

@ -11,10 +11,14 @@
require 'spec_helper'
module Redmine
module OpenProject
module Themes
GoofyTheme = Class.new(Theme)
describe Theme do
before { Theme.clear }
before { ThemeFinder.clear_themes }
# class methods
describe '.new_theme' do
it "returns a new theme" do
@ -28,52 +32,7 @@ module Redmine
end
end
describe '.themes' do
it "returns all instances of descendants of themes" do
theme = Theme.new_theme
expect(Theme.themes).to include theme
end
# the before filter above removes the default theme as well. to test
# the correct behaviour we just spec that the default theme class
# was loaded (by looking through all subclasses of BasicObject)
it "always includes the default theme" do
loaded_classes = Object.descendants
expect(loaded_classes).to include Themes::DefaultTheme
end
# test through the theme instances classes because
# an abstract theme can't have an instance
it "filters out themes marked as abstract" do
theme_class = Class.new(Theme) { abstract! }
theme_classes = Theme.themes.map(&:class)
expect(theme_classes).to_not include theme_class
end
it "subclasses of abstract themes aren't abstract by default" do
abstract_theme_class = Class.new(Theme) { abstract! }
child_theme_class = Class.new(abstract_theme_class)
expect(Theme.themes).to include child_theme_class.instance
end
end
describe '.registered_themes' do
it "returns a hash of themes if their identifiers as keys" do
theme = Theme.new_theme(:new_theme)
expect(Theme.registered_themes).to include :new_theme => theme
end
end
describe '.abstract!' do
it "marks the theme class as abstract" do
theme_class = Class.new(Theme) { abstract! }
expect(Theme.abstract_themes).to include theme_class
end
it "the basic theme class is abstract" do
expect(Theme.abstract_themes).to include Theme
end
it "abstract themes have no instance" do
theme_class = Class.new(Theme) { abstract! }
expect { theme_class.instance }.to raise_error NoMethodError
@ -89,26 +48,7 @@ module Redmine
end
end
describe '.descendants' do
it "it rememberes all classes that descend from Theme" do
theme_class = Class.new(Theme)
expect(Theme.descendants).to include theme_class
end
it "it works on multiple levels" do
theme_class = Class.new(Class.new(Theme))
expect(Theme.descendants).to include theme_class
end
end
describe '.clear' do
it "it wipes out all remembered descendants" do
theme_class = Class.new(Theme)
Theme.clear
expect(Theme.descendants).to be_empty
end
end
# duplicates singleton code, just to make sure
describe '.instance' do
it "is an instance of the class" do
theme_class = Class.new(Theme)
@ -122,22 +62,14 @@ module Redmine
end
describe '.inherited' do
it "it is aware of the new theme (clears the cache when subclassing)" do
Theme.themes
it "is aware of the new theme after inheriting" do
theme = Theme.new_theme
expect(Theme.themes).to include theme
expect(ThemeFinder.themes).to include theme
end
end
describe '.each' do
it "iterates over all themes" do
Theme.new_theme(:new_theme)
themes = []
Theme.each { |theme| themes << theme.identifier }
expect(themes).to eq [:new_theme]
end
end
# instance methods
describe '#assets_path' do
it "should raise exception telling it is sublass responsibility" do
theme = Theme.new_theme(:new_theme)
@ -145,6 +77,27 @@ module Redmine
end
end
describe '#identifier' do
it 'symbolizes the identifier from the class name by default' do
theme = GoofyTheme.instance
expect(theme.identifier).to eq :goofy
end
end
describe '#name' do
it 'titlelizes the name from the class name by default' do
theme = GoofyTheme.instance
expect(theme.name).to eq 'Goofy'
end
end
describe '#stylesheet_manifest' do
it 'stringifies the identier and appends the css extension' do
theme = Theme.new_theme(:goofy)
expect(theme.stylesheet_manifest).to eq 'goofy.css'
end
end
describe '#overridden_images' do
let(:theme) { Theme.new_theme }
@ -258,12 +211,6 @@ module Redmine
expect(Class.new(Theme).instance).to_not eq Class.new(Theme).instance
end
end
describe '#default?' do
it "returns false" do
expect(Theme.new_theme).to_not be_default
end
end
end
describe ViewHelpers do

@ -11,7 +11,7 @@
require 'spec_helper'
module Redmine
module OpenProject
module Themes
describe ViewHelpers do
let(:helpers) { ApplicationController.helpers }

@ -11,7 +11,7 @@
require 'spec_helper'
module Redmine
module OpenProject
describe Themes do
before { Themes.clear_themes }

@ -17,7 +17,7 @@ class ThemesTest < ActionDispatch::IntegrationTest
fixtures :all
def setup
@theme = Redmine::Themes.default_theme
@theme = OpenProject::Themes.default_theme
Setting.ui_theme = @theme.identifier
end

Loading…
Cancel
Save