mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-05 19:11:00 +08:00
This commit is a complete reimplementation of our theme JS compilation system. Previously, we compiled theme JS into AMD `define` statements on a per-source-file basis, and then concatenated them together for the client. These AMD modules would integrate with those in Discourse core, allowing two way access between core/theme modules. Going forward, we'll be moving away from AMD, and towards native ES modules in core. Before we can do that, we need to stop relying on AMD as the 'glue' between core and themes/plugins. This change introduces Rollup (running in mini-racer) as a compiler for theme JS. This is configured to generate a single ES Module which exports a list of 'compat modules'. Core `import()`s the modules for each active theme, and adds them all to AMD. In future, this consumption can be updated to avoid AMD entirely. All module resolution within a theme is handled by Rollup, and does not use AMD. Import of core/plugin modules from themes are automatically transformed into calls to a new `window.moduleBroker` interface. For now, this is a direct interface to AMD. In future, this can be updated to point to real ES Modules in core. Despite the complete overhaul of the internals, this is not a breaking change, and should have no impact on existing themes. If any incompatibilities are found, please report them on https://meta.discourse.org. --------- Co-authored-by: Jarek Radosz <jarek@cvx.dev> Co-authored-by: Chris Manson <chris@manson.ie>
1772 lines
54 KiB
Ruby
1772 lines
54 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
RSpec.describe Theme do
|
|
fab!(:user)
|
|
fab!(:theme) { Fabricate(:theme, user: user) }
|
|
|
|
let(:guardian) { Guardian.new(user) }
|
|
let(:child) { Fabricate(:theme, user: user, component: true) }
|
|
let(:foundation_theme) { Theme.foundation_theme }
|
|
|
|
before { ThemeJavascriptCompiler.disable_terser! }
|
|
|
|
after do
|
|
Theme.clear_cache!
|
|
ThemeJavascriptCompiler.enable_terser!
|
|
end
|
|
|
|
it "can properly clean up color schemes" do
|
|
scheme = ColorScheme.create!(theme_id: theme.id, name: "test")
|
|
scheme2 = ColorScheme.create!(theme_id: theme.id, name: "test2")
|
|
|
|
Fabricate(:theme, color_scheme_id: scheme2.id)
|
|
|
|
theme.destroy!
|
|
scheme2.reload
|
|
|
|
expect(scheme2).not_to eq(nil)
|
|
expect(scheme2.theme_id).to eq(nil)
|
|
expect(ColorScheme.find_by(id: scheme.id)).to eq(nil)
|
|
end
|
|
|
|
it "can support child themes" do
|
|
child.set_field(target: :common, name: "header", value: "World")
|
|
child.set_field(target: :desktop, name: "header", value: "Desktop")
|
|
child.set_field(target: :mobile, name: "header", value: "Mobile")
|
|
|
|
child.save!
|
|
|
|
expect(Theme.lookup_field(child.id, :desktop, "header")).to eq("World\nDesktop")
|
|
expect(Theme.lookup_field(child.id, "mobile", :header)).to eq("World\nMobile")
|
|
|
|
child.set_field(target: :common, name: "header", value: "Worldie")
|
|
child.save!
|
|
|
|
expect(Theme.lookup_field(child.id, :mobile, :header)).to eq("Worldie\nMobile")
|
|
|
|
parent = Fabricate(:theme, user: user)
|
|
|
|
parent.set_field(target: :common, name: "header", value: "Common Parent")
|
|
parent.set_field(target: :mobile, name: "header", value: "Mobile Parent")
|
|
|
|
parent.save!
|
|
|
|
parent.add_relative_theme!(:child, child)
|
|
|
|
expect(Theme.lookup_field(parent.id, :mobile, "header")).to eq(
|
|
"Common Parent\nMobile Parent\nWorldie\nMobile",
|
|
)
|
|
end
|
|
|
|
it "can support parent themes" do
|
|
child.add_relative_theme!(:parent, theme)
|
|
expect(child.parent_themes).to eq([theme])
|
|
end
|
|
|
|
it "can automatically disable for mismatching version" do
|
|
theme.create_remote_theme!(remote_url: "", minimum_discourse_version: "99.99.99")
|
|
theme.save!
|
|
|
|
expect(Theme.transform_ids(theme.id)).to eq([])
|
|
end
|
|
|
|
it "#transform_ids works with nil values" do
|
|
# Used in safe mode
|
|
expect(Theme.transform_ids(nil)).to eq([])
|
|
end
|
|
|
|
it "#transform_ids filters out disabled components" do
|
|
theme.add_relative_theme!(:child, child)
|
|
expect(Theme.transform_ids(theme.id)).to eq([theme.id, child.id])
|
|
child.update!(enabled: false)
|
|
expect(Theme.transform_ids(theme.id)).to eq([theme.id])
|
|
end
|
|
|
|
it "doesn't allow multi-level theme components" do
|
|
grandchild = Fabricate(:theme, user: user)
|
|
grandparent = Fabricate(:theme, user: user)
|
|
|
|
expect do child.add_relative_theme!(:child, grandchild) end.to raise_error(
|
|
Discourse::InvalidParameters,
|
|
I18n.t("themes.errors.no_multilevels_components"),
|
|
)
|
|
|
|
expect do grandparent.add_relative_theme!(:child, theme) end.to raise_error(
|
|
Discourse::InvalidParameters,
|
|
I18n.t("themes.errors.no_multilevels_components"),
|
|
)
|
|
end
|
|
|
|
it "doesn't allow a child to be user selectable" do
|
|
child.update(user_selectable: true)
|
|
expect(child.errors.full_messages).to contain_exactly(
|
|
I18n.t("themes.errors.component_no_user_selectable"),
|
|
)
|
|
end
|
|
|
|
it "doesn't allow a child to be set as the default theme" do
|
|
expect do child.set_default! end.to raise_error(
|
|
Discourse::InvalidParameters,
|
|
I18n.t("themes.errors.component_no_default"),
|
|
)
|
|
end
|
|
|
|
it "doesn't allow a component to have color scheme" do
|
|
scheme = ColorScheme.create!(name: "test")
|
|
child.update(color_scheme: scheme)
|
|
expect(child.errors.full_messages).to contain_exactly(
|
|
I18n.t("themes.errors.component_no_color_scheme"),
|
|
)
|
|
end
|
|
|
|
it "should correct bad html in body_tag_baked and head_tag_baked" do
|
|
theme.set_field(target: :common, name: "head_tag", value: "<b>I am bold")
|
|
theme.save!
|
|
|
|
expect(Theme.lookup_field(theme.id, :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.set_field(target: :common, name: "header", value: with_template)
|
|
theme.save!
|
|
|
|
field = theme.theme_fields.find_by(target_id: Theme.targets[:common], name: "header")
|
|
baked = Theme.lookup_field(theme.id, :mobile, "header")
|
|
|
|
expect(baked).to include(field.javascript_cache.url)
|
|
expect(field.javascript_cache.content).to include("@ember/template-factory")
|
|
expect(field.javascript_cache.content).to include("Raw templates are no longer supported")
|
|
end
|
|
|
|
it "can destroy unbaked theme without errors" 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.set_field(target: :common, name: "header", value: with_template)
|
|
theme.save!
|
|
|
|
field = theme.theme_fields.find_by(target_id: Theme.targets[:common], name: "header")
|
|
baked = Theme.lookup_field(theme.id, :mobile, "header")
|
|
ThemeField.where(id: field.id).update_all(compiler_version: 0) # update_all to avoid callbacks
|
|
|
|
field.reload.destroy!
|
|
end
|
|
|
|
it "should create body_tag_baked on demand if needed" do
|
|
theme.set_field(target: :common, name: :body_tag, value: "<b>test")
|
|
theme.save
|
|
|
|
ThemeField.update_all(value_baked: nil)
|
|
|
|
expect(Theme.lookup_field(theme.id, :desktop, :body_tag)).to match(%r{<b>test</b>})
|
|
end
|
|
|
|
describe "#switch_to_component!" do
|
|
it "correctly converts a theme to component" do
|
|
theme.add_relative_theme!(:child, child)
|
|
scheme = ColorScheme.create!(name: "test")
|
|
theme.update!(color_scheme_id: scheme.id, user_selectable: true)
|
|
theme.set_default!
|
|
|
|
theme.switch_to_component!
|
|
theme.reload
|
|
|
|
expect(theme.component).to eq(true)
|
|
expect(theme.user_selectable).to eq(false)
|
|
expect(theme.default?).to eq(false)
|
|
expect(theme.color_scheme_id).to eq(nil)
|
|
expect(ChildTheme.where(parent_theme: theme).exists?).to eq(false)
|
|
end
|
|
end
|
|
|
|
describe "#switch_to_theme!" do
|
|
it "correctly converts a component to theme" do
|
|
theme.add_relative_theme!(:child, child)
|
|
|
|
child.switch_to_theme!
|
|
theme.reload
|
|
child.reload
|
|
|
|
expect(child.component).to eq(false)
|
|
expect(ChildTheme.where(child_theme: child).exists?).to eq(false)
|
|
end
|
|
end
|
|
|
|
describe ".transform_ids" do
|
|
let!(:orphan1) { Fabricate(:theme, component: true) }
|
|
let!(:child) { Fabricate(:theme, component: true) }
|
|
let!(:child2) { Fabricate(:theme, component: true) }
|
|
let!(:orphan2) { Fabricate(:theme, component: true) }
|
|
let!(:orphan3) { Fabricate(:theme, component: true) }
|
|
let!(:orphan4) { Fabricate(:theme, component: true) }
|
|
|
|
before do
|
|
theme.add_relative_theme!(:child, child)
|
|
theme.add_relative_theme!(:child, child2)
|
|
end
|
|
|
|
it "returns an empty array if no ids are passed" do
|
|
expect(Theme.transform_ids(nil)).to eq([])
|
|
end
|
|
|
|
it "adds the child themes of the parent" do
|
|
sorted = [child.id, child2.id].sort
|
|
|
|
expect(Theme.transform_ids(theme.id)).to eq([theme.id, *sorted])
|
|
end
|
|
end
|
|
|
|
describe "plugin api" do
|
|
def transpile(html)
|
|
f =
|
|
ThemeField.create!(
|
|
target_id: Theme.targets[:mobile],
|
|
theme_id: -1,
|
|
name: "after_header",
|
|
value: html,
|
|
)
|
|
f.ensure_baked!
|
|
[f.value_baked, f.javascript_cache, f]
|
|
end
|
|
|
|
it "transpiles ES6 code" do
|
|
html = <<HTML
|
|
<script type='text/discourse-plugin' version='0.1'>
|
|
const x = 1;
|
|
console.log(x, settings.foo);
|
|
</script>
|
|
HTML
|
|
|
|
baked, javascript_cache, field = transpile(html)
|
|
expect(baked).to include(javascript_cache.url)
|
|
|
|
expect(javascript_cache.content).to include(
|
|
"themeCompatModules[\"discourse/initializers/theme-field-#{field.id}-mobile-html-script-1\"]",
|
|
)
|
|
expect(javascript_cache.content).to include("getObjectForTheme(#{field.theme_id});")
|
|
expect(javascript_cache.content).to include(
|
|
"name: \"theme-field-#{field.id}-mobile-html-script-1\",",
|
|
)
|
|
expect(javascript_cache.content).to include("after: \"inject-objects\",")
|
|
expect(javascript_cache.content).to include("withPluginApi(\"0.1\", api =>")
|
|
expect(javascript_cache.content).to include("const x = 1;")
|
|
end
|
|
end
|
|
|
|
describe "theme upload vars" do
|
|
let :image do
|
|
file_from_fixtures("logo.png")
|
|
end
|
|
|
|
it "can handle uploads based of ThemeField" do
|
|
upload = UploadCreator.new(image, "logo.png").create_for(-1)
|
|
theme.set_field(target: :common, name: :logo, upload_id: upload.id, type: :theme_upload_var)
|
|
theme.set_field(target: :common, name: :scss, value: "body {background-image: url($logo)}")
|
|
theme.save!
|
|
|
|
# make sure we do not nuke it
|
|
freeze_time (SiteSetting.clean_orphan_uploads_grace_period_hours + 1).hours.from_now
|
|
Jobs::CleanUpUploads.new.execute(nil)
|
|
|
|
expect(Upload.where(id: upload.id)).to be_exists
|
|
|
|
# no error for theme field
|
|
theme.reload
|
|
expect(theme.theme_fields.find_by(name: :scss).error).to eq(nil)
|
|
|
|
manager = Stylesheet::Manager.new(theme_id: theme.id)
|
|
|
|
scss, _map =
|
|
Stylesheet::Manager::Builder.new(
|
|
target: :common_theme,
|
|
theme: theme,
|
|
manager: manager,
|
|
).compile(force: true)
|
|
|
|
expect(scss).to include(upload.url)
|
|
end
|
|
end
|
|
|
|
describe "theme settings" do
|
|
it "allows values to be used in scss" do
|
|
theme.set_field(
|
|
target: :settings,
|
|
name: :yaml,
|
|
value: "background_color: red\nfont_size: 25px",
|
|
)
|
|
theme.set_field(
|
|
target: :common,
|
|
name: :scss,
|
|
value: "body {background-color: $background_color; font-size: $font-size}",
|
|
)
|
|
theme.save!
|
|
|
|
manager = Stylesheet::Manager.new(theme_id: theme.id)
|
|
|
|
scss, _map =
|
|
Stylesheet::Manager::Builder.new(
|
|
target: :common_theme,
|
|
theme: theme,
|
|
manager: manager,
|
|
).compile(force: true)
|
|
|
|
expect(scss).to include("background-color:red")
|
|
expect(scss).to include("font-size:25px")
|
|
|
|
setting = theme.settings[:font_size]
|
|
setting.value = "30px"
|
|
theme.save!
|
|
|
|
scss, _map =
|
|
Stylesheet::Manager::Builder.new(
|
|
target: :common_theme,
|
|
theme: theme,
|
|
manager: manager,
|
|
).compile(force: true)
|
|
|
|
expect(scss).to include("font-size:30px")
|
|
|
|
# Escapes correctly. If not, compiling this would throw an exception
|
|
setting.value = <<~SCSS
|
|
\#{$fakeinterpolatedvariable}
|
|
andanothervalue 'withquotes'; margin: 0;
|
|
SCSS
|
|
|
|
theme.set_field(target: :common, name: :scss, value: "body {font-size: quote($font-size)}")
|
|
theme.save!
|
|
|
|
scss, _map =
|
|
Stylesheet::Manager::Builder.new(
|
|
target: :common_theme,
|
|
theme: theme,
|
|
manager: manager,
|
|
).compile(force: true)
|
|
|
|
expect(scss).to include(
|
|
'font-size:"#{$fakeinterpolatedvariable}\a andanothervalue \'withquotes\'; margin: 0;\a"',
|
|
)
|
|
end
|
|
|
|
it "can use a setting straight away after introducing it" do
|
|
theme.set_field(target: :common, name: :scss, value: "body {background-color: red;}")
|
|
theme.save!
|
|
|
|
theme.reload
|
|
theme.set_field(
|
|
target: :settings,
|
|
name: :yaml,
|
|
value: "background_color: red\nfont_size: 25px",
|
|
)
|
|
theme.set_field(
|
|
target: :common,
|
|
name: :scss,
|
|
value: "body {background-color: $background_color;}",
|
|
)
|
|
theme.save!
|
|
|
|
expect(
|
|
theme.theme_fields.find_by(target_id: Theme.targets[:common], name: "scss").error,
|
|
).to eq(nil)
|
|
end
|
|
|
|
it "allows values to be used in JS" do
|
|
theme.name = 'awesome theme"'
|
|
theme.set_field(target: :settings, name: :yaml, value: "name: bob")
|
|
theme_field =
|
|
theme.set_field(
|
|
target: :common,
|
|
name: :after_header,
|
|
value:
|
|
'<script type="text/discourse-plugin" version="1.0">alert(settings.name); let a = ()=>{}; console.log(a);</script>',
|
|
)
|
|
theme.save!
|
|
|
|
theme_field.reload
|
|
expect(Theme.lookup_field(theme.id, :desktop, :after_header)).to include(
|
|
theme_field.javascript_cache.url,
|
|
)
|
|
expect(theme_field.javascript_cache.content).to include <<~JS
|
|
registerSettings(#{theme_field.theme.id}, {
|
|
"name": "bob"
|
|
});
|
|
JS
|
|
expect(theme_field.javascript_cache.content).to include(
|
|
"themeCompatModules[\"discourse/initializers/theme-field-#{theme_field.id}-common-html-script-1\"]",
|
|
)
|
|
expect(theme_field.javascript_cache.content).to include(
|
|
"name: \"theme-field-#{theme_field.id}-common-html-script-1\",",
|
|
)
|
|
expect(theme_field.javascript_cache.content).to include("after: \"inject-objects\",")
|
|
expect(theme_field.javascript_cache.content).to include("withPluginApi(\"1.0\", api =>")
|
|
expect(theme_field.javascript_cache.content).to include("alert(settings.name)")
|
|
expect(theme_field.javascript_cache.content).to include("let a = () => {}")
|
|
|
|
setting = theme.settings[:name]
|
|
setting.value = "bill"
|
|
theme.save!
|
|
|
|
theme_field.reload
|
|
expect(theme_field.javascript_cache.content).to include <<~JS
|
|
registerSettings(#{theme_field.theme.id}, {
|
|
"name": "bill"
|
|
});
|
|
JS
|
|
expect(Theme.lookup_field(theme.id, :desktop, :after_header)).to include(
|
|
theme_field.javascript_cache.url,
|
|
)
|
|
end
|
|
|
|
it "is empty when the settings are invalid" do
|
|
theme.set_field(target: :settings, name: :yaml, value: "nil_setting: ")
|
|
theme.save!
|
|
|
|
expect(theme.settings).to be_empty
|
|
end
|
|
end
|
|
|
|
it "correctly caches theme ids" do
|
|
Theme.where.not(id: theme.id).delete_all
|
|
|
|
theme2 = Fabricate(:theme)
|
|
|
|
expect(Theme.theme_ids).to contain_exactly(theme.id, theme2.id)
|
|
expect(Theme.user_theme_ids).to eq([])
|
|
|
|
theme.update!(user_selectable: true)
|
|
|
|
expect(Theme.user_theme_ids).to contain_exactly(theme.id)
|
|
|
|
theme2.update!(user_selectable: true)
|
|
expect(Theme.user_theme_ids).to contain_exactly(theme.id, theme2.id)
|
|
|
|
theme.update!(user_selectable: false)
|
|
theme2.update!(user_selectable: false)
|
|
|
|
theme.set_default!
|
|
expect(Theme.user_theme_ids).to contain_exactly(theme.id)
|
|
|
|
theme.destroy
|
|
theme2.destroy
|
|
|
|
expect(Theme.theme_ids).to eq([])
|
|
expect(Theme.user_theme_ids).to eq([])
|
|
end
|
|
|
|
it "correctly caches enabled_theme_and_component_ids" do
|
|
Theme.delete_all
|
|
|
|
theme2 = Fabricate(:theme)
|
|
|
|
expect(Theme.enabled_theme_and_component_ids).to eq([])
|
|
|
|
theme2.update!(user_selectable: true)
|
|
|
|
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id)
|
|
|
|
theme2.update!(user_selectable: false)
|
|
theme2.set_default!
|
|
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id)
|
|
|
|
child2 = Fabricate(:theme, component: true)
|
|
theme2.add_relative_theme!(:child, child2)
|
|
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id, child2.id)
|
|
|
|
child2.update!(enabled: false)
|
|
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id)
|
|
|
|
theme3 = Fabricate(:theme, user_selectable: true)
|
|
child2.update!(enabled: true)
|
|
|
|
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(
|
|
theme2.id,
|
|
child2.id,
|
|
theme3.id,
|
|
)
|
|
|
|
theme3.update!(enabled: false)
|
|
|
|
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id, child2.id)
|
|
|
|
theme2.destroy
|
|
theme3.destroy
|
|
|
|
expect(Theme.enabled_theme_and_component_ids).to eq([])
|
|
end
|
|
|
|
it "correctly caches user_themes template" do
|
|
Theme.delete_all
|
|
|
|
json = Site.json_for(guardian)
|
|
user_themes = JSON.parse(json)["user_themes"]
|
|
expect(user_themes).to eq([])
|
|
|
|
theme = Fabricate(:theme, name: "bob", user_selectable: true)
|
|
theme.save!
|
|
|
|
json = Site.json_for(guardian)
|
|
user_themes = JSON.parse(json)["user_themes"].map { |t| t["name"] }
|
|
expect(user_themes).to eq(["bob"])
|
|
|
|
theme.name = "sam"
|
|
theme.save!
|
|
|
|
json = Site.json_for(guardian)
|
|
user_themes = JSON.parse(json)["user_themes"].map { |t| t["name"] }
|
|
expect(user_themes).to eq(["sam"])
|
|
|
|
Theme.destroy_all
|
|
|
|
json = Site.json_for(guardian)
|
|
user_themes = JSON.parse(json)["user_themes"]
|
|
expect(user_themes).to eq([])
|
|
end
|
|
|
|
def cached_settings(id)
|
|
Theme.find_by(id: id).cached_settings.to_json
|
|
end
|
|
|
|
def included_settings(id)
|
|
Theme.find_by(id: id).included_settings.to_json
|
|
end
|
|
|
|
it "clears color scheme cache correctly" do
|
|
Theme.delete_all
|
|
|
|
cs =
|
|
Fabricate(
|
|
:color_scheme,
|
|
name: "Fancy",
|
|
color_scheme_colors: [
|
|
Fabricate(:color_scheme_color, name: "header_primary", hex: "F0F0F0"),
|
|
Fabricate(:color_scheme_color, name: "header_background", hex: "1E1E1E"),
|
|
Fabricate(:color_scheme_color, name: "tertiary", hex: "858585"),
|
|
],
|
|
)
|
|
|
|
theme =
|
|
Fabricate(:theme, user_selectable: true, user: Fabricate(:admin), color_scheme_id: cs.id)
|
|
|
|
theme.set_default!
|
|
|
|
expect(ColorScheme.hex_for_name("header_primary")).to eq("F0F0F0")
|
|
|
|
Theme.clear_default!
|
|
|
|
expect(ColorScheme.hex_for_name("header_primary")).to eq("333")
|
|
end
|
|
|
|
it "correctly notifies about theme changes" do
|
|
cs1 = Fabricate(:color_scheme)
|
|
cs2 = Fabricate(:color_scheme)
|
|
|
|
theme = Fabricate(:theme, user_selectable: true, user: user, color_scheme_id: cs1.id)
|
|
|
|
messages =
|
|
MessageBus
|
|
.track_publish do
|
|
theme.set_field(target: :common, name: :scss, value: "body { color: red; }")
|
|
theme.save!
|
|
end
|
|
.filter { |m| m.channel == "/file-change" }
|
|
expect(messages.count).to eq(1)
|
|
|
|
expect(messages.first.data.map { |d| d[:target] }).to contain_exactly(:common_theme)
|
|
|
|
# With color scheme change:
|
|
messages =
|
|
MessageBus
|
|
.track_publish do
|
|
theme.color_scheme_id = cs2.id
|
|
theme.save!
|
|
end
|
|
.filter { |m| m.channel == "/file-change" }
|
|
expect(messages.count).to eq(1)
|
|
expect(messages.first.data.map { |d| d[:target] }).to contain_exactly(
|
|
:admin,
|
|
:desktop,
|
|
:mobile,
|
|
:common_theme,
|
|
)
|
|
end
|
|
|
|
it "includes theme_uploads in settings" do
|
|
Theme.where.not(id: theme.id).delete_all
|
|
|
|
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
|
|
theme.set_field(type: :theme_upload_var, target: :common, name: "bob", upload_id: upload.id)
|
|
theme.save!
|
|
|
|
json = JSON.parse(cached_settings(theme.id))
|
|
|
|
expect(json["theme_uploads"]["bob"]).to eq(upload.url)
|
|
end
|
|
|
|
it "does not break on missing uploads in settings" do
|
|
Theme.where.not(id: theme.id).delete_all
|
|
|
|
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
|
|
theme.set_field(type: :theme_upload_var, target: :common, name: "bob", upload_id: upload.id)
|
|
theme.save!
|
|
|
|
Upload.find(upload.id).destroy
|
|
theme.remove_from_cache!
|
|
|
|
json = JSON.parse(cached_settings(theme.id))
|
|
expect(json).to be_empty
|
|
end
|
|
|
|
it "uses CDN url for theme_uploads in settings" do
|
|
set_cdn_url("http://cdn.localhost")
|
|
Theme.where.not(id: theme.id).delete_all
|
|
|
|
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
|
|
theme.set_field(type: :theme_upload_var, target: :common, name: "bob", upload_id: upload.id)
|
|
theme.save!
|
|
|
|
json = JSON.parse(cached_settings(theme.id))
|
|
|
|
expect(json["theme_uploads"]["bob"]).to eq("http://cdn.localhost#{upload.url}")
|
|
end
|
|
|
|
it "uses CDN url for settings of type upload" do
|
|
set_cdn_url("http://cdn.localhost")
|
|
Theme.where.not(id: theme.id).delete_all
|
|
|
|
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
|
|
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
|
|
my_upload:
|
|
type: upload
|
|
default: ""
|
|
YAML
|
|
|
|
ThemeSetting.create!(
|
|
theme: theme,
|
|
data_type: ThemeSetting.types[:upload],
|
|
value: upload.id.to_s,
|
|
name: "my_upload",
|
|
)
|
|
theme.save!
|
|
|
|
json = JSON.parse(cached_settings(theme.id))
|
|
expect(json["my_upload"]).to eq("http://cdn.localhost#{upload.url}")
|
|
end
|
|
|
|
describe "theme translations" do
|
|
it "can list working theme_translation_manager objects" do
|
|
en_translation =
|
|
ThemeField.create!(
|
|
theme_id: theme.id,
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: <<~YAML,
|
|
en:
|
|
theme_metadata:
|
|
description: "Description of my theme"
|
|
group_of_translations:
|
|
translation1: en test1
|
|
translation2: en test2
|
|
base_translation1: en test3
|
|
base_translation2: en test4
|
|
YAML
|
|
)
|
|
fr_translation =
|
|
ThemeField.create!(
|
|
theme_id: theme.id,
|
|
name: "fr",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: <<~YAML,
|
|
fr:
|
|
group_of_translations:
|
|
translation2: fr test2
|
|
base_translation2: fr test4
|
|
base_translation3: fr test5
|
|
YAML
|
|
)
|
|
|
|
I18n.locale = :fr
|
|
theme.update_translation("group_of_translations.translation1", "overriddentest1")
|
|
translations = theme.translations
|
|
theme.reload
|
|
|
|
expect(translations.map(&:key)).to eq(
|
|
%w[
|
|
group_of_translations.translation1
|
|
group_of_translations.translation2
|
|
base_translation1
|
|
base_translation2
|
|
base_translation3
|
|
],
|
|
)
|
|
|
|
expect(translations.map(&:default)).to eq(
|
|
["en test1", "fr test2", "en test3", "fr test4", "fr test5"],
|
|
)
|
|
|
|
expect(translations.map(&:value)).to eq(
|
|
["overriddentest1", "fr test2", "en test3", "fr test4", "fr test5"],
|
|
)
|
|
end
|
|
|
|
it "can list internal theme_translation_manager objects" do
|
|
en_translation =
|
|
ThemeField.create!(
|
|
theme_id: theme.id,
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: <<~YAML,
|
|
en:
|
|
theme_metadata:
|
|
description: "Description of my theme"
|
|
another_translation: en test4
|
|
YAML
|
|
)
|
|
translations = theme.internal_translations
|
|
expect(translations.map(&:key)).to contain_exactly("theme_metadata.description")
|
|
expect(translations.map(&:value)).to contain_exactly("Description of my theme")
|
|
end
|
|
|
|
it "can create a hash of overridden values" do
|
|
en_translation =
|
|
ThemeField.create!(
|
|
theme_id: theme.id,
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: <<~YAML,
|
|
en:
|
|
group_of_translations:
|
|
translation1: en test1
|
|
YAML
|
|
)
|
|
|
|
theme.update_translation("group_of_translations.translation1", "overriddentest1")
|
|
I18n.locale = :fr
|
|
theme.update_translation("group_of_translations.translation1", "overriddentest2")
|
|
theme.reload
|
|
expect(theme.translation_override_hash).to eq(
|
|
"en" => {
|
|
"group_of_translations" => {
|
|
"translation1" => "overriddentest1",
|
|
},
|
|
},
|
|
"fr" => {
|
|
"group_of_translations" => {
|
|
"translation1" => "overriddentest2",
|
|
},
|
|
},
|
|
)
|
|
end
|
|
|
|
it "fall back when listing baked field" do
|
|
theme2 = Fabricate(:theme)
|
|
|
|
en_translation =
|
|
ThemeField.create!(
|
|
theme_id: theme.id,
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: "",
|
|
)
|
|
fr_translation =
|
|
ThemeField.create!(
|
|
theme_id: theme.id,
|
|
name: "fr",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: "",
|
|
)
|
|
|
|
en_translation2 =
|
|
ThemeField.create!(
|
|
theme_id: theme2.id,
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: "",
|
|
)
|
|
|
|
expect(
|
|
Theme.list_baked_fields([theme.id, theme2.id], :translations, "fr").map(&:id),
|
|
).to contain_exactly(fr_translation.id, en_translation2.id)
|
|
end
|
|
end
|
|
|
|
describe "automatic recompile" do
|
|
it "must recompile after bumping theme_field version" do
|
|
child.set_field(target: :common, name: "header", value: "World")
|
|
child.set_field(target: :extra_js, name: "test.js.es6", value: "const hello = 'world';")
|
|
child.save!
|
|
|
|
first_common_value = Theme.lookup_field(child.id, :desktop, "header")
|
|
first_extra_js_value = Theme.lookup_field(child.id, :extra_js, nil)
|
|
|
|
Theme
|
|
.stubs(:compiler_version)
|
|
.returns("SOME_NEW_HASH") do
|
|
second_common_value = Theme.lookup_field(child.id, :desktop, "header")
|
|
second_extra_js_value = Theme.lookup_field(child.id, :extra_js, nil)
|
|
|
|
new_common_compiler_version =
|
|
ThemeField.find_by(theme_id: child.id, name: "header").compiler_version
|
|
new_extra_js_compiler_version =
|
|
ThemeField.find_by(theme_id: child.id, name: "test.js.es6").compiler_version
|
|
|
|
expect(first_common_value).to eq(second_common_value)
|
|
expect(first_extra_js_value).to eq(second_extra_js_value)
|
|
|
|
expect(new_common_compiler_version).to eq("SOME_NEW_HASH")
|
|
expect(new_extra_js_compiler_version).to eq("SOME_NEW_HASH")
|
|
end
|
|
end
|
|
|
|
it "recompiles when the hostname changes" do
|
|
theme.set_field(target: :settings, name: :yaml, value: "name: bob")
|
|
theme_field =
|
|
theme.set_field(
|
|
target: :common,
|
|
name: :after_header,
|
|
value:
|
|
'<script type="text/discourse-plugin" version="0.1">console.log("hello world");</script>',
|
|
)
|
|
theme.save!
|
|
|
|
expect(Theme.lookup_field(theme.id, :common, :after_header)).to include(
|
|
"_ws=#{Discourse.current_hostname}",
|
|
)
|
|
|
|
SiteSetting.force_hostname = "someotherhostname.com"
|
|
Theme.clear_cache!
|
|
|
|
expect(Theme.lookup_field(theme.id, :common, :after_header)).to include(
|
|
"_ws=someotherhostname.com",
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "extra_scss" do
|
|
let(:scss) { "body { background: red}" }
|
|
let(:second_file_scss) { "p { color: blue};" }
|
|
let(:child_scss) { "body { background: green}" }
|
|
|
|
let(:theme) do
|
|
Fabricate(:theme).tap do |t|
|
|
t.set_field(target: :extra_scss, name: "my_files/magic", value: scss)
|
|
t.set_field(target: :extra_scss, name: "my_files/magic2", value: second_file_scss)
|
|
t.save!
|
|
end
|
|
end
|
|
|
|
let(:child_theme) do
|
|
Fabricate(:theme).tap do |t|
|
|
t.component = true
|
|
t.set_field(target: :extra_scss, name: "my_files/moremagic", value: child_scss)
|
|
t.save!
|
|
theme.add_relative_theme!(:child, t)
|
|
end
|
|
end
|
|
|
|
let(:compiler) do
|
|
manager = Stylesheet::Manager.new(theme_id: theme.id)
|
|
|
|
builder =
|
|
Stylesheet::Manager::Builder.new(target: :common_theme, theme: theme, manager: manager)
|
|
|
|
builder.compile(force: true)
|
|
end
|
|
|
|
it "works when importing file by path" do
|
|
theme.set_field(target: :common, name: :scss, value: '@import "my_files/magic";')
|
|
theme.save!
|
|
|
|
css, _map = compiler
|
|
expect(css).to include("body{background:red}")
|
|
end
|
|
|
|
it "works when importing multiple files" do
|
|
theme.set_field(
|
|
target: :common,
|
|
name: :scss,
|
|
value: '@import "my_files/magic"; @import "my_files/magic2"',
|
|
)
|
|
theme.save!
|
|
|
|
css, _map = compiler
|
|
expect(css).to include("body{background:red}")
|
|
expect(css).to include("p{color:blue}")
|
|
end
|
|
|
|
it "works for child themes" do
|
|
child_theme.set_field(target: :common, name: :scss, value: '@import "my_files/moremagic"')
|
|
child_theme.save!
|
|
|
|
manager = Stylesheet::Manager.new(theme_id: child_theme.id)
|
|
|
|
builder =
|
|
Stylesheet::Manager::Builder.new(
|
|
target: :common_theme,
|
|
theme: child_theme,
|
|
manager: manager,
|
|
)
|
|
|
|
css, _map = builder.compile(force: true)
|
|
expect(css).to include("body{background:green}")
|
|
end
|
|
end
|
|
|
|
describe "scss_variables" do
|
|
it "is empty by default" do
|
|
expect(theme.scss_variables).to eq(nil)
|
|
end
|
|
|
|
it "includes settings and uploads when set" do
|
|
theme.set_field(
|
|
target: :settings,
|
|
name: :yaml,
|
|
value: "background_color: red\nfont_size: 25px",
|
|
)
|
|
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
|
|
theme.set_field(type: :theme_upload_var, target: :common, name: "bobby", upload_id: upload.id)
|
|
theme.save!
|
|
|
|
expect(theme.scss_variables).to include("$background_color: unquote(\"red\")")
|
|
expect(theme.scss_variables).to include("$font_size: unquote(\"25px\")")
|
|
expect(theme.scss_variables).to include("$bobby: ")
|
|
end
|
|
end
|
|
|
|
describe "#baked_js_tests_with_digest" do
|
|
before do
|
|
ThemeField.create!(
|
|
theme_id: theme.id,
|
|
target_id: Theme.targets[:settings],
|
|
name: "yaml",
|
|
value: "some_number: 1",
|
|
)
|
|
|
|
theme.set_field(
|
|
target: :tests_js,
|
|
type: :js,
|
|
name: "acceptance/some-test.js",
|
|
value: "assert.ok(true);",
|
|
)
|
|
|
|
theme.save!
|
|
end
|
|
|
|
it "returns nil for content and digest if theme does not have tests" do
|
|
ThemeField.destroy_all
|
|
expect(theme.baked_js_tests_with_digest).to eq([nil, nil])
|
|
end
|
|
|
|
it "includes theme's migrations theme fields" do
|
|
theme.set_field(
|
|
target: :migrations,
|
|
type: :js,
|
|
name: "0001-some-migration",
|
|
value: "export default function migrate(settings) { return settings; }",
|
|
)
|
|
|
|
theme.save!
|
|
|
|
content, _digest = theme.baked_js_tests_with_digest
|
|
|
|
expect(content).to include("function migrate(settings)")
|
|
end
|
|
end
|
|
|
|
describe "get_setting" do
|
|
before do
|
|
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
|
|
enabled:
|
|
type: bool
|
|
default: false
|
|
some_value:
|
|
type: string
|
|
default: "hello"
|
|
YAML
|
|
|
|
ThemeSetting.create!(
|
|
theme: theme,
|
|
data_type: ThemeSetting.types[:bool],
|
|
name: "super_feature_enabled",
|
|
)
|
|
|
|
theme.save!
|
|
end
|
|
|
|
it "returns the value of the setting when given a string represeting the setting name" do
|
|
expect(theme.get_setting("enabled")).to eq(false)
|
|
expect(theme.get_setting("some_value")).to eq("hello")
|
|
end
|
|
|
|
it "returns the value of the setting when given a symbol represeting the setting name" do
|
|
expect(theme.get_setting(:enabled)).to eq(false)
|
|
expect(theme.get_setting(:some_value)).to eq("hello")
|
|
end
|
|
end
|
|
|
|
describe "#update_setting" do
|
|
it "requests clients to refresh if `refresh: true`" do
|
|
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
|
|
super_feature_enabled:
|
|
type: bool
|
|
default: false
|
|
refresh: true
|
|
YAML
|
|
|
|
ThemeSetting.create!(
|
|
theme: theme,
|
|
data_type: ThemeSetting.types[:bool],
|
|
name: "super_feature_enabled",
|
|
)
|
|
theme.save!
|
|
|
|
messages =
|
|
MessageBus
|
|
.track_publish do
|
|
theme.update_setting(:super_feature_enabled, true)
|
|
theme.save!
|
|
end
|
|
.filter { |m| m.channel == "/global/asset-version" }
|
|
|
|
expect(messages.count).to eq(1)
|
|
end
|
|
|
|
it "does not request clients to refresh if `refresh: false`" do
|
|
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
|
|
super_feature_enabled:
|
|
type: bool
|
|
default: false
|
|
refresh: false
|
|
YAML
|
|
|
|
ThemeSetting.create!(
|
|
theme: theme,
|
|
data_type: ThemeSetting.types[:bool],
|
|
name: "super_feature_enabled",
|
|
)
|
|
theme.save!
|
|
|
|
messages =
|
|
MessageBus
|
|
.track_publish do
|
|
theme.update_setting(:super_feature_enabled, true)
|
|
theme.save!
|
|
end
|
|
.filter { |m| m.channel == "/global/asset-version" }
|
|
|
|
expect(messages.count).to eq(0)
|
|
end
|
|
end
|
|
|
|
describe "#migrate_settings" do
|
|
fab!(:settings_field) { Fabricate(:settings_theme_field, theme: theme, value: <<~YAML) }
|
|
integer_setting: 1
|
|
list_setting: "aa,bb"
|
|
YAML
|
|
|
|
fab!(:migration_field) { Fabricate(:migration_theme_field, theme: theme, version: 1) }
|
|
|
|
it "persists the results of the last pending migration to the database" do
|
|
migration_field.update!(value: <<~JS)
|
|
export default function migrate(settings) {
|
|
settings.set("integer_setting", 1033);
|
|
settings.set("list_setting", "cc,dd");
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
Fabricate(:migration_theme_field, theme: theme, value: <<~JS, version: 2)
|
|
export default function migrate(settings) {
|
|
settings.set("integer_setting", 9909);
|
|
settings.set("list_setting", "ee,ff");
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
theme.migrate_settings
|
|
expect(theme.get_setting("integer_setting")).to eq(9909)
|
|
expect(theme.get_setting("list_setting")).to eq("ee,ff")
|
|
end
|
|
|
|
it "doesn't allow arbitrary settings to be saved in the database" do
|
|
migration_field.update!(value: <<~JS)
|
|
export default function migrate(settings) {
|
|
settings.set("unknown_setting", 8834);
|
|
return settings;
|
|
}
|
|
JS
|
|
expect do theme.migrate_settings end.to raise_error(
|
|
Theme::SettingsMigrationError,
|
|
I18n.t(
|
|
"themes.import_error.migrations.unknown_setting_returned_by_migration",
|
|
name: "0001-some-name",
|
|
setting_name: "unknown_setting",
|
|
),
|
|
)
|
|
end
|
|
|
|
it "updates the theme's javascript cache after running migration" do
|
|
theme.set_field(target: :extra_js, name: "test.js.es6", value: "const hello = 'world';")
|
|
theme.save!
|
|
|
|
expect(theme.javascript_cache.content).to include('"list_setting": "aa,bb"')
|
|
|
|
settings_field.update!(value: <<~YAML)
|
|
integer_setting: 1
|
|
list_setting:
|
|
default: aa|bb
|
|
type: list
|
|
YAML
|
|
|
|
migration_field.update!(value: <<~JS)
|
|
export default function migrate(settings) {
|
|
settings.set("list_setting", "zz|aa");
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
theme.reload
|
|
theme.migrate_settings
|
|
|
|
setting_record = theme.theme_settings.where(name: "list_setting").first
|
|
|
|
expect(setting_record.data_type).to eq(ThemeSetting.types[:list])
|
|
expect(setting_record.value).to eq("zz|aa")
|
|
expect(theme.javascript_cache.content).to include('"list_setting": "zz|aa"')
|
|
end
|
|
|
|
it "allows changing a setting's type" do
|
|
theme.update_setting(:list_setting, "zz,aa")
|
|
theme.save!
|
|
|
|
setting_record = theme.theme_settings.where(name: "list_setting").first
|
|
|
|
expect(setting_record.data_type).to eq(ThemeSetting.types[:string])
|
|
expect(setting_record.value).to eq("zz,aa")
|
|
|
|
settings_field.update!(value: <<~YAML)
|
|
integer_setting: 1
|
|
list_setting:
|
|
default: aa|bb
|
|
type: list
|
|
YAML
|
|
|
|
migration_field.update!(value: <<~JS)
|
|
export default function migrate(settings) {
|
|
settings.set("list_setting", "zz|aa");
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
theme.reload
|
|
|
|
theme.migrate_settings
|
|
|
|
expect(theme.theme_settings.where(name: "list_setting").count).to eq(1)
|
|
setting_record = theme.theme_settings.where(name: "list_setting").first
|
|
|
|
expect(setting_record.data_type).to eq(ThemeSetting.types[:list])
|
|
expect(setting_record.value).to eq("zz|aa")
|
|
|
|
expect(
|
|
theme.theme_settings_migrations.where(theme_field_id: migration_field.id).first.diff,
|
|
).to eq(
|
|
"additions" => [{ "key" => "list_setting", "val" => "zz|aa" }],
|
|
"deletions" => [{ "key" => "list_setting", "val" => "zz,aa" }],
|
|
)
|
|
end
|
|
|
|
it "allows renaming a setting" do
|
|
theme.update_setting(:integer_setting, 11)
|
|
theme.save!
|
|
|
|
setting_record = theme.theme_settings.where(name: "integer_setting").first
|
|
expect(setting_record.value).to eq("11")
|
|
|
|
settings_field.update!(value: <<~YAML)
|
|
integer_setting_updated: 1
|
|
list_setting: "aa,bb"
|
|
YAML
|
|
migration_field.update!(value: <<~JS)
|
|
export default function migrate(settings) {
|
|
settings.set("integer_setting_updated", settings.get("integer_setting"));
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
theme.reload
|
|
|
|
theme.migrate_settings
|
|
|
|
expect(theme.theme_settings.where(name: "integer_setting").exists?).to eq(false)
|
|
|
|
setting_record = theme.theme_settings.where(name: "integer_setting_updated").first
|
|
expect(setting_record.value).to eq("11")
|
|
|
|
expect(
|
|
theme.theme_settings_migrations.where(theme_field_id: migration_field.id).first.diff,
|
|
).to eq(
|
|
"additions" => [{ "key" => "integer_setting_updated", "val" => 11 }],
|
|
"deletions" => [{ "key" => "integer_setting", "val" => 11 }],
|
|
)
|
|
end
|
|
|
|
it "creates a ThemeSettingsMigration record for each migration" do
|
|
migration_field.update!(value: <<~JS)
|
|
export default function migrate(settings) {
|
|
settings.set("integer_setting", 2);
|
|
settings.set("list_setting", "cc,dd");
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
second_migration_field =
|
|
Fabricate(:migration_theme_field, theme: theme, value: <<~JS, version: 2)
|
|
export default function migrate(settings) {
|
|
settings.set("integer_setting", 3);
|
|
settings.set("list_setting", "ee,ff");
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
third_migration_field =
|
|
Fabricate(:migration_theme_field, theme: theme, value: <<~JS, version: 3)
|
|
export default function migrate(settings) {
|
|
settings.set("integer_setting", 4);
|
|
settings.set("list_setting", "gg,hh");
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
theme.migrate_settings
|
|
|
|
records = theme.theme_settings_migrations.order(:version)
|
|
|
|
expect(records.count).to eq(3)
|
|
|
|
expect(records[0].version).to eq(1)
|
|
expect(records[0].name).to eq("some-name")
|
|
expect(records[0].theme_field_id).to eq(migration_field.id)
|
|
expect(records[0].diff).to eq(
|
|
"additions" => [
|
|
{ "key" => "integer_setting", "val" => 2 },
|
|
{ "key" => "list_setting", "val" => "cc,dd" },
|
|
],
|
|
"deletions" => [],
|
|
)
|
|
|
|
expect(records[1].version).to eq(2)
|
|
expect(records[1].name).to eq("some-name")
|
|
expect(records[1].theme_field_id).to eq(second_migration_field.id)
|
|
expect(records[1].diff).to eq(
|
|
"additions" => [
|
|
{ "key" => "integer_setting", "val" => 3 },
|
|
{ "key" => "list_setting", "val" => "ee,ff" },
|
|
],
|
|
"deletions" => [
|
|
{ "key" => "integer_setting", "val" => 2 },
|
|
{ "key" => "list_setting", "val" => "cc,dd" },
|
|
],
|
|
)
|
|
|
|
expect(records[2].version).to eq(3)
|
|
expect(records[2].name).to eq("some-name")
|
|
expect(records[2].theme_field_id).to eq(third_migration_field.id)
|
|
expect(records[2].diff).to eq(
|
|
"additions" => [
|
|
{ "key" => "integer_setting", "val" => 4 },
|
|
{ "key" => "list_setting", "val" => "gg,hh" },
|
|
],
|
|
"deletions" => [
|
|
{ "key" => "integer_setting", "val" => 3 },
|
|
{ "key" => "list_setting", "val" => "ee,ff" },
|
|
],
|
|
)
|
|
end
|
|
|
|
it "allows removing an old setting that no longer exists" do
|
|
settings_field.update!(value: <<~YAML)
|
|
setting_that_will_be_removed: 1
|
|
YAML
|
|
theme.update_setting(:setting_that_will_be_removed, 1023)
|
|
theme.save!
|
|
|
|
settings_field.update!(value: <<~YAML)
|
|
new_setting: 1
|
|
YAML
|
|
migration_field.update!(value: <<~JS)
|
|
export default function migrate(settings) {
|
|
if (settings.get("setting_that_will_be_removed") !== 1023) {
|
|
throw new Error(`expected setting_that_will_be_removed to be 1023, but it was instead ${settings.get("setting_that_will_be_removed")}.`);
|
|
}
|
|
settings.delete("setting_that_will_be_removed");
|
|
return settings;
|
|
}
|
|
JS
|
|
theme.reload
|
|
theme.migrate_settings
|
|
theme.reload
|
|
|
|
expect(theme.theme_settings.count).to eq(0)
|
|
|
|
records = theme.theme_settings_migrations
|
|
expect(records.size).to eq(1)
|
|
|
|
expect(records[0].diff).to eq(
|
|
"additions" => [],
|
|
"deletions" => [{ "key" => "setting_that_will_be_removed", "val" => 1023 }],
|
|
)
|
|
end
|
|
|
|
it "does not raise an out of sequence error and does not create `ThemeSettingsMigration` record for out of sequence migration when `allow_out_of_sequence_migration` kwarg is set to true" do
|
|
second_migration_field =
|
|
Fabricate(
|
|
:migration_theme_field,
|
|
name: "0001-some-other-migration-name",
|
|
theme: theme,
|
|
value: <<~JS,
|
|
export default function migrate(settings) {
|
|
settings.set("integer_setting", 3);
|
|
return settings;
|
|
}
|
|
JS
|
|
version: 1,
|
|
)
|
|
|
|
expect do theme.migrate_settings end.to raise_error(
|
|
Theme::SettingsMigrationError,
|
|
/'0001-some-other-migration-name' is out of sequence/,
|
|
)
|
|
|
|
expect(theme.get_setting("integer_setting")).to eq(1)
|
|
|
|
theme.migrate_settings(allow_out_of_sequence_migration: true)
|
|
|
|
expect(theme.theme_settings_migrations.count).to eq(1)
|
|
expect(theme.theme_settings_migrations.first.theme_field_id).to eq(migration_field.id)
|
|
expect(theme.get_setting("integer_setting")).to eq(3)
|
|
end
|
|
|
|
it "allows custom migration fields to be run by specifing the `fields` kwarg" do
|
|
expect do theme.migrate_settings(fields: []) end.not_to change {
|
|
theme.theme_settings_migrations.count
|
|
}
|
|
|
|
second_migration_field =
|
|
Fabricate(:migration_theme_field, theme: theme, value: <<~JS, version: 2)
|
|
export default function migrate(settings) {
|
|
settings.set("integer_setting", 3);
|
|
return settings;
|
|
}
|
|
JS
|
|
|
|
theme.migrate_settings(fields: [second_migration_field])
|
|
|
|
expect(theme.theme_settings_migrations.count).to eq(1)
|
|
expect(theme.theme_settings_migrations.first.theme_field_id).to eq(second_migration_field.id)
|
|
expect(theme.get_setting("integer_setting")).to eq(3)
|
|
end
|
|
end
|
|
|
|
describe "development experience" do
|
|
it "sends 'development-mode-theme-changed event when non-css fields are updated" do
|
|
Theme.any_instance.stubs(:should_refresh_development_clients?).returns(true)
|
|
|
|
theme.set_field(target: :common, name: :scss, value: "body {background: green;}")
|
|
|
|
messages =
|
|
MessageBus
|
|
.track_publish { theme.save! }
|
|
.filter { |m| m.channel == "/file-change" }
|
|
.map(&:data)
|
|
|
|
expect(messages).not_to include("development-mode-theme-changed")
|
|
|
|
theme.set_field(target: :common, name: :header, value: "<p>Hello world</p>")
|
|
|
|
messages =
|
|
MessageBus
|
|
.track_publish { theme.save! }
|
|
.filter { |m| m.channel == "/file-change" }
|
|
.map(&:data)
|
|
|
|
expect(messages).to include(["development-mode-theme-changed"])
|
|
end
|
|
end
|
|
|
|
describe "#lookup_field when a theme component is used in multiple themes" do
|
|
fab!(:theme_1) { Fabricate(:theme, user: user) }
|
|
fab!(:theme_2) { Fabricate(:theme, user: user) }
|
|
fab!(:child) { Fabricate(:theme, user: user, component: true) }
|
|
|
|
before_all do
|
|
theme_1.add_relative_theme!(:child, child)
|
|
theme_2.add_relative_theme!(:child, child)
|
|
end
|
|
|
|
it "efficiently caches fields of theme component by only caching the fields once across multiple themes" do
|
|
child.set_field(target: :common, name: "header", value: "World")
|
|
child.save!
|
|
|
|
expect(Theme.lookup_field(theme_1.id, :desktop, "header")).to eq("World")
|
|
expect(Theme.lookup_field(theme_2.id, :desktop, "header")).to eq("World")
|
|
|
|
expect(
|
|
Theme.cache.defer_get_set("#{child.id}:common:header:#{Theme.compiler_version}") { raise },
|
|
).to eq(["World"])
|
|
expect(
|
|
Theme.cache.defer_get_set("#{child.id}:desktop:header:#{Theme.compiler_version}") { raise },
|
|
).to eq(nil)
|
|
|
|
expect(
|
|
Theme
|
|
.cache
|
|
.defer_get_set("#{theme_1.id}:common:header:#{Theme.compiler_version}") { raise },
|
|
).to eq(nil)
|
|
expect(
|
|
Theme
|
|
.cache
|
|
.defer_get_set("#{theme_1.id}:desktop:header:#{Theme.compiler_version}") { raise },
|
|
).to eq(nil)
|
|
|
|
expect(
|
|
Theme
|
|
.cache
|
|
.defer_get_set("#{theme_2.id}:common:header:#{Theme.compiler_version}") { raise },
|
|
).to eq(nil)
|
|
expect(
|
|
Theme
|
|
.cache
|
|
.defer_get_set("#{theme_2.id}:desktop:header:#{Theme.compiler_version}") { raise },
|
|
).to eq(nil)
|
|
end
|
|
|
|
it "puts the parent value ahead of the child" do
|
|
theme_1.set_field(target: :common, name: "header", value: "theme_1")
|
|
theme_1.save!
|
|
|
|
child.set_field(target: :common, name: "header", value: "child")
|
|
child.save!
|
|
|
|
expect(Theme.lookup_field(theme_1.id, :desktop, "header")).to eq("theme_1\nchild")
|
|
end
|
|
|
|
it "puts parent translations ahead of child translations" do
|
|
theme_1.set_field(target: :translations, name: "en", value: <<~YAML)
|
|
en:
|
|
theme_1: "test"
|
|
YAML
|
|
theme_1.save!
|
|
theme_field = ThemeField.order(:id).last
|
|
|
|
child.set_field(target: :translations, name: "en", value: <<~YAML)
|
|
en:
|
|
child: "test"
|
|
YAML
|
|
child.save!
|
|
child_field = ThemeField.order(:id).last
|
|
|
|
expect(theme_field.value_baked).not_to eq(child_field.value_baked)
|
|
expect(Theme.lookup_field(theme_1.id, :translations, :en)).to eq(
|
|
[theme_field, child_field].map(&:value_baked).join("\n"),
|
|
)
|
|
end
|
|
|
|
it "prioritizes a locale over its fallback" do
|
|
theme_1.set_field(target: :translations, name: "en", value: <<~YAML)
|
|
en:
|
|
theme_1: "hello"
|
|
YAML
|
|
theme_1.save!
|
|
en_field = ThemeField.order(:id).last
|
|
|
|
theme_1.set_field(target: :translations, name: "es", value: <<~YAML)
|
|
es:
|
|
theme_1: "hola"
|
|
YAML
|
|
theme_1.save!
|
|
es_field = ThemeField.order(:id).last
|
|
|
|
expect(es_field.value_baked).not_to eq(en_field.value_baked)
|
|
expect(Theme.lookup_field(theme_1.id, :translations, :en)).to eq(en_field.value_baked)
|
|
expect(Theme.lookup_field(theme_1.id, :translations, :es)).to eq(es_field.value_baked)
|
|
expect(Theme.lookup_field(theme_1.id, :translations, :fr)).to eq(en_field.value_baked)
|
|
end
|
|
end
|
|
|
|
describe "#repository_url" do
|
|
subject(:repository_url) { theme.repository_url }
|
|
|
|
context "when theme is not a remote one" do
|
|
it "returns nothing" do
|
|
expect(repository_url).to be_blank
|
|
end
|
|
end
|
|
|
|
context "when theme is a remote one" do
|
|
let!(:remote_theme) { theme.create_remote_theme(remote_url: remote_url) }
|
|
|
|
context "when URL is a SSH one" do
|
|
let(:remote_url) { "git@github.com:discourse/graceful.git" }
|
|
|
|
it "normalizes it" do
|
|
expect(repository_url).to eq "github.com/discourse/graceful"
|
|
end
|
|
end
|
|
|
|
context "when URL is a HTTPS one" do
|
|
let(:remote_url) { "https://github.com/discourse/graceful.git" }
|
|
|
|
it "normalizes it" do
|
|
expect(repository_url).to eq "github.com/discourse/graceful"
|
|
end
|
|
end
|
|
|
|
context "when URL is a HTTP one" do
|
|
let(:remote_url) { "http://github.com/discourse/graceful" }
|
|
|
|
it "normalizes it" do
|
|
expect(repository_url).to eq "github.com/discourse/graceful"
|
|
end
|
|
end
|
|
|
|
context "when URL contains query params" do
|
|
let(:remote_url) { "http://github.com/discourse/graceful.git?param_id=1" }
|
|
|
|
it "keeps the query params" do
|
|
expect(repository_url).to eq "github.com/discourse/graceful?param_id=1"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#user_selectable_count" do
|
|
subject(:count) { theme.user_selectable_count }
|
|
|
|
let!(:users) { Fabricate.times(5, :user) }
|
|
let!(:another_theme) { Fabricate(:theme) }
|
|
|
|
before do
|
|
users.take(3).each { _1.user_option.update!(theme_ids: [theme.id]) }
|
|
users.slice(3..4).each { _1.user_option.update!(theme_ids: [another_theme.id]) }
|
|
end
|
|
|
|
it "returns how many users are currently using the theme" do
|
|
expect(count).to eq 3
|
|
end
|
|
end
|
|
|
|
describe "#owned_color_scheme" do
|
|
it "is destroyed when the theme is destroyed" do
|
|
scheme = Fabricate(:color_scheme, owning_theme: theme)
|
|
|
|
theme.destroy!
|
|
|
|
expect(ThemeColorScheme.exists?(color_scheme_id: scheme.id)).to eq(false)
|
|
expect(ColorScheme.unscoped.exists?(id: scheme.id)).to eq(false)
|
|
end
|
|
end
|
|
|
|
describe ".include_basic_relations" do
|
|
fab!(:parent_theme_1) do
|
|
Fabricate(
|
|
:theme,
|
|
theme_fields: [
|
|
ThemeField.new(
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: <<~YAML,
|
|
en:
|
|
theme_metadata:
|
|
description: "Description of my theme"
|
|
YAML
|
|
),
|
|
],
|
|
)
|
|
end
|
|
|
|
fab!(:parent_theme_2) do
|
|
Fabricate(
|
|
:theme,
|
|
theme_fields: [
|
|
ThemeField.new(
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: <<~YAML,
|
|
en:
|
|
theme_metadata:
|
|
description: "Description of my theme 2"
|
|
YAML
|
|
),
|
|
],
|
|
)
|
|
end
|
|
|
|
fab!(:component_1) do
|
|
Fabricate(
|
|
:theme,
|
|
component: true,
|
|
parent_themes: [parent_theme_1],
|
|
theme_fields: [
|
|
ThemeField.new(
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: <<~YAML,
|
|
en:
|
|
theme_metadata:
|
|
description: "Description of my component"
|
|
YAML
|
|
),
|
|
],
|
|
)
|
|
end
|
|
|
|
fab!(:component_2) do
|
|
Fabricate(
|
|
:theme,
|
|
component: true,
|
|
parent_themes: [parent_theme_2],
|
|
theme_fields: [
|
|
ThemeField.new(
|
|
name: "en",
|
|
type_id: ThemeField.types[:yaml],
|
|
target_id: Theme.targets[:translations],
|
|
value: <<~YAML,
|
|
en:
|
|
theme_metadata:
|
|
description: "Description of my component 2"
|
|
YAML
|
|
),
|
|
],
|
|
)
|
|
end
|
|
|
|
it "doesn't result in N+1 queries for descriptions" do
|
|
components = Theme.include_basic_relations.where(component: true, id: component_1.id)
|
|
|
|
queries_for_one =
|
|
track_sql_queries do
|
|
components.each do |component|
|
|
ComponentIndexSerializer.new(component, root: false).as_json
|
|
end
|
|
end
|
|
|
|
components =
|
|
Theme.include_basic_relations.where(component: true, id: [component_1.id, component_2.id])
|
|
|
|
queries_for_two =
|
|
track_sql_queries do
|
|
components.each do |component|
|
|
ComponentIndexSerializer.new(component, root: false).as_json
|
|
end
|
|
end
|
|
|
|
expect(queries_for_two.size).to eq(queries_for_one.size)
|
|
end
|
|
end
|
|
|
|
describe "#screenshot_url" do
|
|
it "returns nil when no screenshot is set" do
|
|
expect(theme.screenshot_url).to be_nil
|
|
end
|
|
|
|
it "returns the upload URL when screenshot is set" do
|
|
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
|
|
theme.set_field(
|
|
target: :common,
|
|
name: "screenshot",
|
|
upload_id: upload.id,
|
|
type: :theme_screenshot_upload_var,
|
|
)
|
|
theme.save!
|
|
expect(theme.screenshot_url).to eq(upload.url)
|
|
end
|
|
end
|
|
|
|
describe "#find_or_create_owned_color_palette" do
|
|
it "correctly associates a theme with its owned color palette" do
|
|
palette = theme.find_or_create_owned_color_palette
|
|
|
|
expect(palette.owning_theme).to eq(theme)
|
|
expect(theme.reload.owned_color_palette).to eq(palette)
|
|
end
|
|
|
|
it "ensures owned color palette is not user selectable" do
|
|
palette = theme.find_or_create_owned_color_palette
|
|
|
|
expect(palette.user_selectable).to eq(false)
|
|
end
|
|
|
|
it "copies colors from base or theme color scheme" do
|
|
theme_without_scheme = Fabricate(:theme, color_scheme: nil)
|
|
base_palette = theme_without_scheme.find_or_create_owned_color_palette
|
|
|
|
expect(base_palette.colors.length).to be > 0
|
|
expect(base_palette.colors.map(&:name).sort).to eq(ColorScheme.base.colors.map(&:name).sort)
|
|
|
|
custom_palette =
|
|
Fabricate(
|
|
:color_scheme,
|
|
colors: [ColorSchemeColor.new(name: "custom", hex: "11ccff", dark_hex: "ee9955")],
|
|
)
|
|
theme_with_scheme = Fabricate(:theme, color_scheme: custom_palette)
|
|
custom_palette = theme_with_scheme.find_or_create_owned_color_palette
|
|
|
|
expect(custom_palette.colors.length).to be > 0
|
|
expect(custom_palette.colors.map(&:name).sort).to eq(custom_palette.colors.map(&:name).sort)
|
|
end
|
|
|
|
it "returns the existing palette if a race condition occurs and a theme-owned palette is created while it's executing" do
|
|
expect(theme.owned_color_palette).to eq(nil)
|
|
|
|
palette = Fabricate(:color_scheme)
|
|
ThemeColorScheme.create!(theme_id: theme.id, color_scheme_id: palette.id)
|
|
|
|
expect(theme.owned_color_palette).to eq(nil)
|
|
expect(theme.find_or_create_owned_color_palette.id).to eq(palette.id)
|
|
expect(theme.owned_color_palette).to eq(palette)
|
|
end
|
|
end
|
|
|
|
it "checks if fields can be updated for system themes" do
|
|
foundation_theme.update!(user_selectable: true)
|
|
expect(foundation_theme.user_selectable).to be true
|
|
expect { foundation_theme.update!(name: "edited system name") }.to raise_error(
|
|
Discourse::InvalidParameters,
|
|
)
|
|
expect { theme.update!(name: "edited name") }.not_to raise_error
|
|
end
|
|
|
|
it "does not allow system themes to be deleted" do
|
|
expect { foundation_theme.destroy! }.to raise_error(Discourse::InvalidParameters)
|
|
expect { theme.destroy! }.not_to raise_error
|
|
end
|
|
|
|
describe "#system?" do
|
|
it "returns system true for Horizon and Foundation themes" do
|
|
expect(foundation_theme.system?).to be true
|
|
expect(theme.system?).to be false
|
|
end
|
|
end
|
|
end
|