FEATURE: Native theme support

This feature introduces the concept of themes. Themes are an evolution
of site customizations.

Themes introduce two very big conceptual changes:

- A theme may include other "child themes", children can include grand
children and so on.

- A theme may specify a color scheme

The change does away with the idea of "enabled" color schemes.

It also adds a bunch of big niceties like

- You can source a theme from a git repo

- History for themes is much improved

- You can only have a single enabled theme. Themes can be selected by
    users, if you opt for it.

On a technical level this change comes with a whole bunch of goodies

- All CSS is now compiled using a custom pipeline that uses libsass
    see /lib/stylesheet

- There is a single pipeline for css compilation (in the past we used
    one for customizations and another one for the rest of the app

- The stylesheet pipeline is now divorced of sprockets, there is no
   reliance on sprockets for CSS bundling

- CSS is generated with source maps everywhere (including themes) this
    makes debugging much easier

- Our "live reloader" is smarter and avoid a flash of unstyled content
   we run a file watcher in "puma" in dev so you no longer need to run
   rake autospec to watch for CSS changes
This commit is contained in:
Sam
2017-04-12 10:52:52 -04:00
parent 1a9afa976d
commit a3e8c3cd7b
163 changed files with 4415 additions and 2424 deletions

View File

@@ -1,30 +0,0 @@
require 'rails_helper'
require_dependency 'sass/discourse_sass_compiler'
describe DiscourseSassCompiler do
let(:test_scss) { "body { p {color: blue;} }\n@import 'common/foundation/variables';\n@import 'plugins';" }
describe '#compile' do
it "compiles scss" do
DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"])
css = described_class.compile(test_scss, "test")
expect(css).to include("color")
expect(css).to include('my-plugin-thing')
end
it "raises error for invalid scss" do
expect {
described_class.compile("this isn't valid scss", "test")
}.to raise_error(Sass::SyntaxError)
end
it "doesn't load theme or plugins in safe mode" do
ColorScheme.expects(:enabled).never
DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"])
css = described_class.compile(test_scss, "test", safe: true)
expect(css).not_to include('my-plugin-thing')
end
end
end

View File

@@ -1,46 +0,0 @@
require 'rails_helper'
require_dependency 'sass/discourse_stylesheets'
describe DiscourseStylesheets do
describe "compile" do
it "can compile desktop bundle" do
DiscoursePluginRegistry.stubs(:stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"])
builder = described_class.new(:desktop)
expect(builder.compile(force: true)).to include('my-plugin-thing')
FileUtils.rm builder.stylesheet_fullpath
end
it "can compile mobile bundle" do
DiscoursePluginRegistry.stubs(:mobile_stylesheets).returns(["#{Rails.root}/spec/fixtures/scss/my_plugin.scss"])
builder = described_class.new(:mobile)
expect(builder.compile(force: true)).to include('my-plugin-thing')
FileUtils.rm builder.stylesheet_fullpath
end
it "can fallback when css is bad" do
DiscoursePluginRegistry.stubs(:stylesheets).returns([
"#{Rails.root}/spec/fixtures/scss/my_plugin.scss",
"#{Rails.root}/spec/fixtures/scss/broken.scss"
])
builder = described_class.new(:desktop)
expect(builder.compile(force: true)).not_to include('my-plugin-thing')
FileUtils.rm builder.stylesheet_fullpath
end
end
describe "#digest" do
before do
described_class.expects(:max_file_mtime).returns(Time.new(2016, 06, 05, 12, 30, 0, 0))
end
it "should return a digest" do
expect(described_class.new.digest).to eq('0e6c2e957cfc92ed60661c90ec3345198ccef887')
end
it "should include the cdn url when generating the digest" do
GlobalSetting.expects(:cdn_url).returns('https://fastly.maxcdn.org')
expect(described_class.new.digest).to eq('4995163b1232c54c8ed3b44200d803a90bc47613')
end
end
end

View File

@@ -151,27 +151,31 @@ describe Wizard::StepUpdater do
let!(:color_scheme) { Fabricate(:color_scheme, name: 'existing', via_wizard: true) }
it "updates the scheme" do
updater = wizard.create_updater('colors', theme_id: 'dark')
updater = wizard.create_updater('colors', base_scheme_id: 'dark')
updater.update
expect(updater.success?).to eq(true)
expect(wizard.completed_steps?('colors')).to eq(true)
color_scheme.reload
expect(color_scheme).to be_enabled
theme = Theme.find_by(key: SiteSetting.default_theme_key)
expect(theme.color_scheme_id).to eq(color_scheme.id)
end
end
context "without an existing scheme" do
it "creates the scheme" do
updater = wizard.create_updater('colors', theme_id: 'dark')
updater = wizard.create_updater('colors', base_scheme_id: 'dark')
updater.update
expect(updater.success?).to eq(true)
expect(wizard.completed_steps?('colors')).to eq(true)
color_scheme = ColorScheme.where(via_wizard: true).first
expect(color_scheme).to be_present
expect(color_scheme).to be_enabled
expect(color_scheme.colors).to be_present
theme = Theme.find_by(key: SiteSetting.default_theme_key)
expect(theme.color_scheme_id).to eq(color_scheme.id)
end
end
end

View File

@@ -0,0 +1,21 @@
require 'rails_helper'
require 'stylesheet/compiler'
describe Stylesheet::Compiler do
it "can compile desktop mobile and desktop css" do
css,_map = Stylesheet::Compiler.compile_asset("desktop")
expect(css.length).to be > 1000
css,_map = Stylesheet::Compiler.compile_asset("mobile")
expect(css.length).to be > 1000
end
it "supports asset-url" do
css,_map = Stylesheet::Compiler.compile(".body{background-image: asset-url('foo.png');}","test.scss")
expect(css).to include("url('/foo.png')")
expect(css).not_to include('asset-url')
end
end

View File

@@ -0,0 +1,57 @@
require 'rails_helper'
require 'stylesheet/compiler'
describe Stylesheet::Manager do
it 'can correctly compile theme css' do
theme = Theme.new(
name: 'parent',
user_id: -1
)
theme.set_field(:common, "scss", ".common{.scss{color: red;}}")
theme.set_field(:desktop, "scss", ".desktop{.scss{color: red;}}")
theme.set_field(:mobile, "scss", ".mobile{.scss{color: red;}}")
theme.set_field(:common, "embedded_scss", ".embedded{.scss{color: red;}}")
theme.save!
child_theme = Theme.new(
name: 'parent',
user_id: -1,
)
child_theme.set_field(:common, "scss", ".child_common{.scss{color: red;}}")
child_theme.set_field(:desktop, "scss", ".child_desktop{.scss{color: red;}}")
child_theme.set_field(:mobile, "scss", ".child_mobile{.scss{color: red;}}")
child_theme.set_field(:common, "embedded_scss", ".child_embedded{.scss{color: red;}}")
child_theme.save!
theme.add_child_theme!(child_theme)
old_link = Stylesheet::Manager.stylesheet_link_tag(:desktop_theme, 'all', theme.key)
manager = Stylesheet::Manager.new(:desktop_theme, theme.key)
manager.compile(force: true)
css = File.read(manager.stylesheet_fullpath)
_source_map = File.read(manager.source_map_fullpath)
expect(css).to match(/child_common/)
expect(css).to match(/child_desktop/)
expect(css).to match(/\.common/)
expect(css).to match(/\.desktop/)
child_theme.set_field(:desktop, :scss, ".nothing{color: green;}")
child_theme.save!
new_link = Stylesheet::Manager.stylesheet_link_tag(:desktop_theme, 'all', theme.key)
expect(new_link).not_to eq(old_link)
# our theme better have a name with the theme_id as part of it
expect(new_link).to include("/stylesheets/desktop_theme_#{theme.id}_")
end
end

View File

@@ -9,7 +9,6 @@ describe Admin::ColorSchemesController do
let!(:user) { log_in(:admin) }
let(:valid_params) { { color_scheme: {
name: 'Such Design',
enabled: true,
colors: [
{name: 'primary', hex: 'FFBB00'},
{name: 'secondary', hex: '888888'}

View File

@@ -1,48 +0,0 @@
require 'rails_helper'
describe Admin::SiteCustomizationsController do
it "is a subclass of AdminController" do
expect(Admin::UsersController < Admin::AdminController).to eq(true)
end
context 'while logged in as an admin' do
before do
@user = log_in(:admin)
end
context ' .index' do
it 'returns success' do
SiteCustomization.create!(name: 'my name', user_id: Fabricate(:user).id, header: "my awesome header", stylesheet: "my awesome css")
xhr :get, :index
expect(response).to be_success
end
it 'returns JSON' do
xhr :get, :index
expect(::JSON.parse(response.body)).to be_present
end
end
context ' .create' do
it 'returns success' do
xhr :post, :create, site_customization: {name: 'my test name'}
expect(response).to be_success
end
it 'returns json' do
xhr :post, :create, site_customization: {name: 'my test name'}
expect(::JSON.parse(response.body)).to be_present
end
it 'logs the change' do
StaffActionLogger.any_instance.expects(:log_site_customization_change).once
xhr :post, :create, site_customization: {name: 'my test name'}
end
end
end
end

View File

@@ -8,15 +8,35 @@ describe Admin::StaffActionLogsController do
let!(:user) { log_in(:admin) }
context '.index' do
before do
it 'works' do
xhr :get, :index
expect(response).to be_success
expect(::JSON.parse(response.body)).to be_a(Array)
end
end
subject { response }
it { is_expected.to be_success }
context '.diff' do
it 'can generate diffs for theme changes' do
theme = Theme.new(user_id: -1, name: 'bob')
theme.set_field(:mobile, :scss, 'body {.up}')
theme.set_field(:common, :scss, 'omit-dupe')
it 'returns JSON' do
expect(::JSON.parse(subject.body)).to be_a(Array)
original_json = ThemeSerializer.new(theme, root: false).to_json
theme.set_field(:mobile, :scss, 'body {.down}')
record = StaffActionLogger.new(Discourse.system_user)
.log_theme_change(original_json, theme)
xhr :get, :diff, id: record.id
expect(response).to be_success
parsed = JSON.parse(response.body)
expect(parsed["side_by_side"]).to include("up")
expect(parsed["side_by_side"]).to include("down")
expect(parsed["side_by_side"]).not_to include("omit-dupe")
end
end
end

View File

@@ -0,0 +1,101 @@
require 'rails_helper'
describe Admin::ThemesController do
it "is a subclass of AdminController" do
expect(Admin::UsersController < Admin::AdminController).to eq(true)
end
context 'while logged in as an admin' do
before do
@user = log_in(:admin)
end
context ' .index' do
it 'returns success' do
theme = Theme.new(name: 'my name', user_id: -1)
theme.set_field(:common, :scss, '.body{color: black;}')
theme.set_field(:desktop, :after_header, '<b>test</b>')
theme.remote_theme = RemoteTheme.new(
remote_url: 'awesome.git',
remote_version: '7',
local_version: '8',
remote_updated_at: Time.zone.now
)
theme.save!
# this will get serialized as well
ColorScheme.create_from_base(name: "test", colors: [])
xhr :get, :index
expect(response).to be_success
json = ::JSON.parse(response.body)
expect(json["extras"]["color_schemes"].length).to eq(2)
theme_json = json["themes"].find{|t| t["id"] == theme.id}
expect(theme_json["theme_fields"].length).to eq(2)
expect(theme_json["remote_theme"]["remote_version"]).to eq("7")
end
end
context ' .create' do
it 'creates a theme' do
xhr :post, :create, theme: {name: 'my test name', theme_fields: [name: 'scss', target: 'common', value: 'body{color: red;}']}
expect(response).to be_success
json = ::JSON.parse(response.body)
expect(json["theme"]["theme_fields"].length).to eq(1)
expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
end
end
context ' .update' do
it 'can change default theme' do
theme = Theme.create(name: 'my name', user_id: -1)
xhr :put, :update, id: theme.id, theme: { default: true }
expect(SiteSetting.default_theme_key).to eq(theme.key)
end
it 'can unset default theme' do
theme = Theme.create(name: 'my name', user_id: -1)
SiteSetting.default_theme_key = theme.key
xhr :put, :update, id: theme.id, theme: { default: false}
expect(SiteSetting.default_theme_key).to be_blank
end
it 'updates a theme' do
theme = Theme.new(name: 'my name', user_id: -1)
theme.set_field(:common, :scss, '.body{color: black;}')
theme.save
child_theme = Theme.create(name: 'my name', user_id: -1)
xhr :put, :update, id: theme.id,
theme: {
child_theme_ids: [child_theme.id],
name: 'my test name',
theme_fields: [name: 'scss', target: 'common', value: 'body{color: red;}']
}
expect(response).to be_success
json = ::JSON.parse(response.body)
fields = json["theme"]["theme_fields"]
expect(fields.length).to eq(1)
expect(fields.first["value"]).to eq('body{color: red;}')
expect(json["theme"]["child_themes"].length).to eq(1)
expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
end
end
end
end

View File

@@ -1,45 +0,0 @@
require 'rails_helper'
describe SiteCustomizationsController do
before do
SiteCustomization.clear_cache!
end
it 'can deliver enabled css' do
SiteCustomization.create!(name: '1',
user_id: -1,
enabled: true,
mobile_stylesheet: '.a1{margin: 1px;}',
stylesheet: '.b1{margin: 1px;}'
)
SiteCustomization.create!(name: '2',
user_id: -1,
enabled: true,
mobile_stylesheet: '.a2{margin: 1px;}',
stylesheet: '.b2{margin: 1px;}'
)
get :show, key: SiteCustomization::ENABLED_KEY, format: :css, target: 'mobile'
expect(response.body).to match(/\.a1.*\.a2/m)
get :show, key: SiteCustomization::ENABLED_KEY, format: :css
expect(response.body).to match(/\.b1.*\.b2/m)
end
it 'can deliver specific css' do
c = SiteCustomization.create!(name: '1',
user_id: -1,
enabled: true,
mobile_stylesheet: '.a1{margin: 1px;}',
stylesheet: '.b1{margin: 1px;}'
)
get :show, key: c.key, format: :css, target: 'mobile'
expect(response.body).to match(/\.a1/)
get :show, key: c.key, format: :css
expect(response.body).to match(/\.b1/)
end
end

View File

@@ -5,7 +5,7 @@ describe StylesheetsController do
it 'can survive cache miss' do
StylesheetCache.destroy_all
builder = DiscourseStylesheets.new('desktop_rtl')
builder = Stylesheet::Manager.new('desktop_rtl', nil)
builder.compile
builder.ensure_digestless_file
@@ -26,7 +26,7 @@ describe StylesheetsController do
expect(cached.digest).to eq digest
# tmp folder destruction and cached
`rm #{DiscourseStylesheets.cache_fullpath}/*`
`rm #{Stylesheet::Manager.cache_fullpath}/*`
get :show, name: 'desktop_rtl'
expect(response).to be_success
@@ -38,4 +38,31 @@ describe StylesheetsController do
end
it 'can lookup theme specific css' do
scheme = ColorScheme.create_from_base({name: "testing", colors: []})
theme = Theme.create!(name: "test", color_scheme_id: scheme.id, user_id: -1)
builder = Stylesheet::Manager.new(:desktop, theme.key)
builder.compile
`rm #{Stylesheet::Manager.cache_fullpath}/*`
get :show, name: builder.stylesheet_filename.sub(".css", "")
expect(response).to be_success
get :show, name: builder.stylesheet_filename_no_digest.sub(".css", "")
expect(response).to be_success
builder = Stylesheet::Manager.new(:desktop_theme, theme.key)
builder.compile
`rm #{Stylesheet::Manager.cache_fullpath}/*`
get :show, name: builder.stylesheet_filename.sub(".css", "")
expect(response).to be_success
get :show, name: builder.stylesheet_filename_no_digest.sub(".css", "")
expect(response).to be_success
end
end

View File

@@ -1,5 +1,4 @@
Fabricator(:color_scheme) do
name { sequence(:name) {|i| "Palette #{i}" } }
enabled false
color_scheme_colors(count: 2) { |attrs, i| Fabricate.build(:color_scheme_color, color_scheme: nil) }
end

View File

@@ -2,7 +2,7 @@ require 'rails_helper'
describe ColorScheme do
let(:valid_params) { {name: "Best Colors Evar", enabled: true, colors: valid_colors} }
let(:valid_params) { {name: "Best Colors Evar", colors: valid_colors} }
let(:valid_colors) { [
{name: '$primary_background_color', hex: 'FFBB00'},
{name: '$secondary_background_color', hex: '888888'}
@@ -10,7 +10,7 @@ describe ColorScheme do
describe "new" do
it "can take colors" do
c = described_class.new(valid_params)
c = ColorScheme.new(valid_params)
expect(c.colors.size).to eq valid_colors.size
expect(c.colors.first).to be_a(ColorSchemeColor)
expect {
@@ -55,29 +55,4 @@ describe ColorScheme do
end
end
end
describe "destroy" do
it "also destroys old versions" do
c1 = described_class.create(valid_params.merge(version: 2))
_c2 = described_class.create(valid_params.merge(versioned_id: c1.id, version: 1))
_other = described_class.create(valid_params)
expect {
c1.destroy
}.to change { described_class.count }.by(-2)
end
end
describe "#enabled" do
it "returns nil when there is no enabled record" do
expect(described_class.enabled).to eq nil
end
it "returns the enabled color scheme" do
ColorScheme.hex_cache.clear
expect(described_class.hex_for_name('$primary_background_color')).to eq nil
c = described_class.create(valid_params.merge(enabled: true))
expect(described_class.enabled.id).to eq c.id
expect(described_class.hex_for_name('$primary_background_color')).to eq "FFBB00"
end
end
end

View File

@@ -0,0 +1,84 @@
require 'rails_helper'
describe RemoteTheme do
context '#import_remote' do
def setup_git_repo(files)
dir = Dir.tmpdir
repo_dir = "#{dir}/#{SecureRandom.hex}"
`mkdir #{repo_dir}`
`cd #{repo_dir} && git init .`
`cd #{repo_dir} && mkdir desktop mobile common`
files.each do |name, data|
File.write("#{repo_dir}/#{name}", data)
`cd #{repo_dir} && git add #{name}`
end
`cd #{repo_dir} && git commit -am 'first commit'`
repo_dir
end
let :initial_repo do
setup_git_repo(
"about.json" => '{
"name": "awesome theme",
"about_url": "https://www.site.com/about",
"license_url": "https://www.site.com/license"
}',
"desktop/desktop.scss" => "body {color: red;}",
"common/header.html" => "I AM HEADER",
"common/random.html" => "I AM SILLY",
)
end
after do
`rm -fr #{initial_repo}`
end
it 'can correctly import a remote theme' do
time = Time.new('2000')
freeze_time time
@theme = RemoteTheme.import_theme(initial_repo)
remote = @theme.remote_theme
expect(@theme.name).to eq('awesome theme')
expect(remote.remote_url).to eq(initial_repo)
expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip)
expect(remote.local_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip)
expect(remote.about_url).to eq("https://www.site.com/about")
expect(remote.license_url).to eq("https://www.site.com/license")
expect(@theme.theme_fields.length).to eq(2)
mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target}-#{f.name}", f.value]}.flatten]
expect(mapped["0-header"]).to eq("I AM HEADER")
expect(mapped["1-scss"]).to eq("body {color: red;}")
expect(remote.remote_updated_at).to eq(time)
File.write("#{initial_repo}/common/header.html", "I AM UPDATED")
`cd #{initial_repo} && git commit -am "update"`
time = Time.new('2001')
freeze_time time
remote.update_remote_version
expect(remote.commits_behind).to eq(1)
expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip)
remote.update_from_remote
@theme.save
@theme.reload
mapped = Hash[*@theme.theme_fields.map{|f| ["#{f.target}-#{f.name}", f.value]}.flatten]
expect(mapped["0-header"]).to eq("I AM UPDATED")
expect(mapped["1-scss"]).to eq("body {color: red;}")
expect(remote.remote_updated_at).to eq(time)
end
end
end

View File

@@ -1,155 +0,0 @@
require 'rails_helper'
describe SiteCustomization do
before do
SiteCustomization.clear_cache!
end
let :user do
Fabricate(:user)
end
let :customization_params do
{name: 'my name', user_id: user.id, header: "my awesome header", stylesheet: "my awesome css", mobile_stylesheet: nil, mobile_header: nil}
end
let :customization do
SiteCustomization.create!(customization_params)
end
let :customization_with_mobile do
SiteCustomization.create!(customization_params.merge(mobile_stylesheet: ".mobile {better: true;}", mobile_header: "fancy mobile stuff"))
end
it 'should set default key when creating a new customization' do
s = SiteCustomization.create!(name: 'my name', user_id: user.id)
expect(s.key).not_to eq(nil)
end
it 'can enable more than one style at once' do
c1 = SiteCustomization.create!(name: '2', user_id: user.id, header: 'World',
enabled: true, mobile_header: 'hi', footer: 'footer',
stylesheet: '.hello{.world {color: blue;}}')
SiteCustomization.create!(name: '1', user_id: user.id, header: 'Hello',
enabled: true, mobile_footer: 'mfooter',
mobile_stylesheet: '.hello{margin: 1px;}',
stylesheet: 'p{width: 1px;}'
)
expect(SiteCustomization.custom_header).to eq("Hello\nWorld")
expect(SiteCustomization.custom_header(nil, :mobile)).to eq("hi")
expect(SiteCustomization.custom_footer(nil, :mobile)).to eq("mfooter")
expect(SiteCustomization.custom_footer).to eq("footer")
desktop_css = SiteCustomization.custom_stylesheet
expect(desktop_css).to match(Regexp.new("#{SiteCustomization::ENABLED_KEY}.css\\?target=desktop"))
mobile_css = SiteCustomization.custom_stylesheet(nil, :mobile)
expect(mobile_css).to match(Regexp.new("#{SiteCustomization::ENABLED_KEY}.css\\?target=mobile"))
expect(SiteCustomization.enabled_stylesheet_contents).to match(/\.hello \.world/)
# cache expiry
c1.enabled = false
c1.save
expect(SiteCustomization.custom_stylesheet).not_to eq(desktop_css)
expect(SiteCustomization.enabled_stylesheet_contents).not_to match(/\.hello \.world/)
end
it 'should be able to look up stylesheets by key' do
c = SiteCustomization.create!(name: '2', user_id: user.id,
enabled: true,
stylesheet: '.hello{.world {color: blue;}}',
mobile_stylesheet: '.world{.hello{color: black;}}')
expect(SiteCustomization.custom_stylesheet(c.key, :mobile)).to match(Regexp.new("#{c.key}.css\\?target=mobile"))
expect(SiteCustomization.custom_stylesheet(c.key)).to match(Regexp.new("#{c.key}.css\\?target=desktop"))
end
it 'should allow including discourse styles' do
c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '@import "desktop";', mobile_stylesheet: '@import "mobile";')
expect(c.stylesheet_baked).not_to match(/Syntax error/)
expect(c.stylesheet_baked.length).to be > 1000
expect(c.mobile_stylesheet_baked).not_to match(/Syntax error/)
expect(c.mobile_stylesheet_baked.length).to be > 1000
end
it 'should provide an awesome error on failure' do
c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: "$black: #000; #a { color: $black; }\n\n\nboom", header: '')
expect(c.stylesheet_baked).to match(/Syntax error/)
expect(c.mobile_stylesheet_baked).not_to be_present
end
it 'should provide an awesome error on failure for mobile too' do
c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '', header: '', mobile_stylesheet: "$black: #000; #a { color: $black; }\n\n\nboom", mobile_header: '')
expect(c.mobile_stylesheet_baked).to match(/Syntax error/)
expect(c.stylesheet_baked).not_to be_present
end
it 'should correct bad html in body_tag_baked and head_tag_baked' do
c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: "<b>I am bold", body_tag: "<b>I am bold")
expect(c.head_tag_baked).to eq("<b>I am bold</b>")
expect(c.body_tag_baked).to eq("<b>I am bold</b>")
end
it 'should precompile fragments in body and head tags' do
with_template = <<HTML
<script type='text/x-handlebars' name='template'>
{{hello}}
</script>
<script type='text/x-handlebars' data-template-name='raw_template.raw'>
{{hello}}
</script>
HTML
c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: with_template, body_tag: with_template)
expect(c.head_tag_baked).to match(/HTMLBars/)
expect(c.body_tag_baked).to match(/HTMLBars/)
expect(c.body_tag_baked).to match(/raw-handlebars/)
expect(c.head_tag_baked).to match(/raw-handlebars/)
end
it 'should create body_tag_baked on demand if needed' do
c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: "<b>test", enabled: true)
c.update_columns(head_tag_baked: nil)
expect(SiteCustomization.custom_head_tag).to match(/<b>test<\/b>/)
end
context "plugin api" do
def transpile(html)
c = SiteCustomization.create!(user_id: -1, name: "test", head_tag: html, body_tag: html)
c.head_tag_baked
end
it "transpiles ES6 code" do
html = <<HTML
<script type='text/discourse-plugin' version='0.1'>
const x = 1;
</script>
HTML
transpiled = transpile(html)
expect(transpiled).to match(/\<script\>/)
expect(transpiled).to match(/var x = 1;/)
expect(transpiled).to match(/_registerPluginCode\('0.1'/)
end
it "converts errors to a script type that is not evaluated" do
html = <<HTML
<script type='text/discourse-plugin' version='0.1'>
const x = 1;
x = 2;
</script>
HTML
transpiled = transpile(html)
expect(transpiled).to match(/text\/discourse-js-error/)
expect(transpiled).to match(/read-only/)
end
end
end

View File

@@ -2,6 +2,44 @@ require 'rails_helper'
require_dependency 'site'
describe Site do
def expect_correct_themes(guardian)
json = Site.json_for(guardian)
parsed = JSON.parse(json)
expected = Theme.where('key = :default OR user_selectable',
default: SiteSetting.default_theme_key)
.order(:name)
.pluck(:key, :name)
.map{|k,n| {"theme_key" => k, "name" => n, "default" => k == SiteSetting.default_theme_key}}
expect(parsed["user_themes"]).to eq(expected)
end
it "includes user themes and expires them as needed" do
default_theme = Theme.create!(user_id: -1, name: 'default')
SiteSetting.default_theme_key = default_theme.key
user_theme = Theme.create!(user_id: -1, name: 'user theme', user_selectable: true)
anon_guardian = Guardian.new
user_guardian = Guardian.new(Fabricate(:user))
expect_correct_themes(anon_guardian)
expect_correct_themes(user_guardian)
Theme.clear_default!
expect_correct_themes(anon_guardian)
expect_correct_themes(user_guardian)
user_theme.user_selectable = false
user_theme.save!
expect_correct_themes(anon_guardian)
expect_correct_themes(user_guardian)
end
it "omits categories users can not write to from the category list" do
category = Fabricate(:category)
user = Fabricate(:user)

View File

@@ -5,7 +5,7 @@ describe StylesheetCache do
describe "add" do
it "correctly cycles once MAX_TO_KEEP is hit" do
(StylesheetCache::MAX_TO_KEEP + 1).times do |i|
StylesheetCache.add("a", "d" + i.to_s, "c" + i.to_s)
StylesheetCache.add("a", "d" + i.to_s, "c" + i.to_s, "map")
end
expect(StylesheetCache.count).to eq StylesheetCache::MAX_TO_KEEP
@@ -13,8 +13,8 @@ describe StylesheetCache do
end
it "does nothing if digest is set and already exists" do
StylesheetCache.add("a", "b", "c")
StylesheetCache.add("a", "b", "cc")
StylesheetCache.add("a", "b", "c", "map")
StylesheetCache.add("a", "b", "cc", "map")
expect(StylesheetCache.count).to eq 1
expect(StylesheetCache.first.content).to eq "c"

141
spec/models/theme_spec.rb Normal file
View File

@@ -0,0 +1,141 @@
require 'rails_helper'
describe Theme do
before do
Theme.clear_cache!
end
let :user do
Fabricate(:user)
end
let :customization_params do
{name: 'my name', user_id: user.id, header: "my awesome header"}
end
let :customization do
Theme.create!(customization_params)
end
it 'should set default key when creating a new customization' do
s = Theme.create!(name: 'my name', user_id: user.id)
expect(s.key).not_to eq(nil)
end
it 'can support child themes' do
child = Theme.new(name: '2', user_id: user.id)
child.set_field(:common, "header", "World")
child.set_field(:desktop, "header", "Desktop")
child.set_field(:mobile, "header", "Mobile")
child.save!
expect(Theme.lookup_field(child.key, :desktop, "header")).to eq("World\nDesktop")
expect(Theme.lookup_field(child.key, "mobile", :header)).to eq("World\nMobile")
child.set_field(:common, "header", "Worldie")
child.save!
expect(Theme.lookup_field(child.key, :mobile, :header)).to eq("Worldie\nMobile")
parent = Theme.new(name: '1', user_id: user.id)
parent.set_field(:common, "header", "Common Parent")
parent.set_field(:mobile, "header", "Mobile Parent")
parent.save!
parent.add_child_theme!(child)
expect(Theme.lookup_field(parent.key, :mobile, "header")).to eq("Common Parent\nMobile Parent\nWorldie\nMobile")
end
it 'can correctly find parent themes' do
grandchild = Theme.create!(name: 'grandchild', user_id: user.id)
child = Theme.create!(name: 'child', user_id: user.id)
theme = Theme.create!(name: 'theme', user_id: user.id)
theme.add_child_theme!(child)
child.add_child_theme!(grandchild)
expect(grandchild.dependant_themes.length).to eq(2)
end
it 'should correct bad html in body_tag_baked and head_tag_baked' do
theme = Theme.new(user_id: -1, name: "test")
theme.set_field(:common, "head_tag", "<b>I am bold")
theme.save!
expect(Theme.lookup_field(theme.key, :desktop, "head_tag")).to eq("<b>I am bold</b>")
end
it 'should precompile fragments in body and head tags' do
with_template = <<HTML
<script type='text/x-handlebars' name='template'>
{{hello}}
</script>
<script type='text/x-handlebars' data-template-name='raw_template.raw'>
{{hello}}
</script>
HTML
theme = Theme.new(user_id: -1, name: "test")
theme.set_field(:common, "header", with_template)
theme.save!
baked = Theme.lookup_field(theme.key, :mobile, "header")
expect(baked).to match(/HTMLBars/)
expect(baked).to match(/raw-handlebars/)
end
it 'should create body_tag_baked on demand if needed' do
theme = Theme.new(user_id: -1, name: "test")
theme.set_field(:common, :body_tag, "<b>test")
theme.save
ThemeField.update_all(value_baked: nil)
expect(Theme.lookup_field(theme.key, :desktop, :body_tag)).to match(/<b>test<\/b>/)
end
context "plugin api" do
def transpile(html)
f = ThemeField.create!(target: Theme.targets[:mobile], theme_id: -1, name: "after_header", value: html)
f.value_baked
end
it "transpiles ES6 code" do
html = <<HTML
<script type='text/discourse-plugin' version='0.1'>
const x = 1;
</script>
HTML
transpiled = transpile(html)
expect(transpiled).to match(/\<script\>/)
expect(transpiled).to match(/var x = 1;/)
expect(transpiled).to match(/_registerPluginCode\('0.1'/)
end
it "converts errors to a script type that is not evaluated" do
html = <<HTML
<script type='text/discourse-plugin' version='0.1'>
const x = 1;
x = 2;
</script>
HTML
transpiled = transpile(html)
expect(transpiled).to match(/text\/discourse-js-error/)
expect(transpiled).to match(/read-only/)
end
end
end

View File

@@ -3,62 +3,42 @@ require 'rails_helper'
describe ColorSchemeRevisor do
let(:color) { Fabricate.build(:color_scheme_color, hex: 'FFFFFF', color_scheme: nil) }
let(:color_scheme) { Fabricate(:color_scheme, enabled: false, created_at: 1.day.ago, updated_at: 1.day.ago, color_scheme_colors: [color]) }
let(:valid_params) { { name: color_scheme.name, enabled: color_scheme.enabled, colors: nil } }
let(:color_scheme) { Fabricate(:color_scheme, created_at: 1.day.ago, updated_at: 1.day.ago, color_scheme_colors: [color]) }
let(:valid_params) { { name: color_scheme.name, colors: nil } }
describe "revise" do
it "does nothing if there are no changes" do
expect {
described_class.revise(color_scheme, valid_params.merge(colors: nil))
ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: nil))
}.to_not change { color_scheme.reload.updated_at }
end
it "can change the name" do
described_class.revise(color_scheme, valid_params.merge(name: "Changed Name"))
ColorSchemeRevisor.revise(color_scheme, valid_params.merge(name: "Changed Name"))
expect(color_scheme.reload.name).to eq("Changed Name")
end
it "can update the theme_id" do
described_class.revise(color_scheme, valid_params.merge(theme_id: 'test'))
expect(color_scheme.reload.theme_id).to eq('test')
it "can update the base_scheme_id" do
ColorSchemeRevisor.revise(color_scheme, valid_params.merge(base_scheme_id: 'test'))
expect(color_scheme.reload.base_scheme_id).to eq('test')
end
it "can enable and disable" do
described_class.revise(color_scheme, valid_params.merge(enabled: true))
expect(color_scheme.reload).to be_enabled
described_class.revise(color_scheme, valid_params.merge(enabled: false))
expect(color_scheme.reload).not_to be_enabled
end
def test_color_change(color_scheme_arg, expected_enabled)
described_class.revise(color_scheme_arg, valid_params.merge(colors: [
{name: color.name, hex: 'BEEF99'}
it 'can change colors' do
ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: [
{name: color.name, hex: 'BEEF99'},
{name: 'bob', hex: 'AAAAAA'}
]))
color_scheme_arg.reload
expect(color_scheme_arg.enabled).to eq(expected_enabled)
expect(color_scheme_arg.colors.size).to eq(1)
expect(color_scheme_arg.colors.first.hex).to eq('BEEF99')
end
color_scheme.reload
it "can change colors of a color scheme that's not enabled" do
test_color_change(color_scheme, false)
end
it "can change colors of the enabled color scheme" do
color_scheme.update_attribute(:enabled, true)
test_color_change(color_scheme, true)
end
it "disables other color scheme before enabling" do
prev_enabled = Fabricate(:color_scheme, enabled: true)
described_class.revise(color_scheme, valid_params.merge(enabled: true))
expect(prev_enabled.reload.enabled).to eq(false)
expect(color_scheme.reload.enabled).to eq(true)
expect(color_scheme.version).to eq(2)
expect(color_scheme.colors.size).to eq(2)
expect(color_scheme.colors.find_by(name: color.name).hex).to eq('BEEF99')
expect(color_scheme.colors.find_by(name: 'bob').hex).to eq('AAAAAA')
end
it "doesn't make changes when a color is invalid" do
expect {
cs = described_class.revise(color_scheme, valid_params.merge(colors: [
cs = ColorSchemeRevisor.revise(color_scheme, valid_params.merge(colors: [
{name: color.name, hex: 'OOPS'}
]))
expect(cs).not_to be_valid
@@ -66,72 +46,6 @@ describe ColorSchemeRevisor do
}.to_not change { color_scheme.reload.version }
expect(color_scheme.colors.first.hex).to eq(color.hex)
end
describe "versions" do
it "doesn't create a new version if colors is not given" do
expect {
described_class.revise(color_scheme, valid_params.merge(name: "Changed Name"))
}.to_not change { color_scheme.reload.version }
end
it "creates a new version if colors have changed" do
old_hex = color.hex
expect {
described_class.revise(color_scheme, valid_params.merge(colors: [
{name: color.name, hex: 'BEEF99'}
]))
}.to change { color_scheme.reload.version }.by(1)
old_version = ColorScheme.find_by(versioned_id: color_scheme.id, version: (color_scheme.version - 1))
expect(old_version).not_to eq(nil)
expect(old_version.colors.count).to eq(color_scheme.colors.count)
expect(old_version.colors_by_name[color.name].hex).to eq(old_hex)
expect(color_scheme.colors_by_name[color.name].hex).to eq('BEEF99')
end
it "doesn't create a new version if colors have not changed" do
expect {
described_class.revise(color_scheme, valid_params.merge(colors: [
{name: color.name, hex: color.hex}
]))
}.to_not change { color_scheme.reload.version }
end
end
end
describe "revert" do
context "when there are no previous versions" do
it "does nothing" do
expect {
expect(described_class.revert(color_scheme)).to eq(color_scheme)
}.to_not change { color_scheme.reload.version }
end
end
context 'when there are previous versions' do
let(:new_color_params) { {name: color.name, hex: 'BEEF99'} }
before do
@prev_hex = color.hex
described_class.revise(color_scheme, valid_params.merge(colors: [ new_color_params ]))
end
it "reverts the colors to the previous version" do
expect(color_scheme.colors_by_name[new_color_params[:name]].hex).to eq(new_color_params[:hex])
expect {
described_class.revert(color_scheme)
}.to change { color_scheme.reload.version }.by(-1)
expect(color_scheme.colors.size).to eq(1)
expect(color_scheme.colors.first.hex).to eq(@prev_hex)
expect(color_scheme.colors_by_name[new_color_params[:name]].hex).to eq(@prev_hex)
end
it "destroys the old version's record" do
expect {
described_class.revert(color_scheme)
}.to change { ColorScheme.count }.by(-1)
expect(color_scheme.reload.previous_version).to eq(nil)
end
end
end
end

View File

@@ -129,46 +129,56 @@ describe StaffActionLogger do
end
end
describe "log_site_customization_change" do
let(:valid_params) { {name: 'Cool Theme', stylesheet: "body {\n background-color: blue;\n}\n", header: "h1 {color: white;}"} }
describe "log_theme_change" do
it "raises an error when params are invalid" do
expect { logger.log_site_customization_change(nil, nil) }.to raise_error(Discourse::InvalidParameters)
expect { logger.log_theme_change(nil, nil) }.to raise_error(Discourse::InvalidParameters)
end
let :theme do
Theme.new(name: 'bob', user_id: -1)
end
it "logs new site customizations" do
log_record = logger.log_site_customization_change(nil, valid_params)
expect(log_record.subject).to eq(valid_params[:name])
log_record = logger.log_theme_change(nil, theme)
expect(log_record.subject).to eq(theme.name)
expect(log_record.previous_value).to eq(nil)
expect(log_record.new_value).to be_present
json = ::JSON.parse(log_record.new_value)
expect(json['stylesheet']).to be_present
expect(json['header']).to be_present
expect(json['name']).to eq(theme.name)
end
it "logs updated site customizations" do
existing = SiteCustomization.new(name: 'Banana', stylesheet: "body {color: yellow;}", header: "h1 {color: brown;}")
log_record = logger.log_site_customization_change(existing, valid_params)
old_json = ThemeSerializer.new(theme, root:false).to_json
theme.set_field(:common, :scss, "body{margin: 10px;}")
log_record = logger.log_theme_change(old_json, theme)
expect(log_record.previous_value).to be_present
json = ::JSON.parse(log_record.previous_value)
expect(json['stylesheet']).to eq(existing.stylesheet)
expect(json['header']).to eq(existing.header)
json = ::JSON.parse(log_record.new_value)
expect(json['theme_fields']).to eq([{"name" => "scss", "target" => "common", "value" => "body{margin: 10px;}"}])
end
end
describe "log_site_customization_destroy" do
describe "log_theme_destroy" do
it "raises an error when params are invalid" do
expect { logger.log_site_customization_destroy(nil) }.to raise_error(Discourse::InvalidParameters)
expect { logger.log_theme_destroy(nil) }.to raise_error(Discourse::InvalidParameters)
end
it "creates a new UserHistory record" do
site_customization = SiteCustomization.new(name: 'Banana', stylesheet: "body {color: yellow;}", header: "h1 {color: brown;}")
log_record = logger.log_site_customization_destroy(site_customization)
theme = Theme.new(name: 'Banana')
theme.set_field(:common, :scss, "body{margin: 10px;}")
log_record = logger.log_theme_destroy(theme)
expect(log_record.previous_value).to be_present
expect(log_record.new_value).to eq(nil)
json = ::JSON.parse(log_record.previous_value)
expect(json['stylesheet']).to eq(site_customization.stylesheet)
expect(json['header']).to eq(site_customization.header)
expect(json['theme_fields']).to eq([{"name" => "scss", "target" => "common", "value" => "body{margin: 10px;}"}])
end
end