diff --git a/app/assets/javascripts/admin/components/admin-theme-editor.js.es6 b/app/assets/javascripts/admin/components/admin-theme-editor.js.es6 index c9d4c803de3..19e5cc38279 100644 --- a/app/assets/javascripts/admin/components/admin-theme-editor.js.es6 +++ b/app/assets/javascripts/admin/components/admin-theme-editor.js.es6 @@ -27,6 +27,7 @@ export default Ember.Component.extend({ @computed("currentTargetName", "fieldName") activeSectionMode(targetName, fieldName) { if (["settings", "translations"].includes(targetName)) return "yaml"; + if (["extra_scss"].includes(targetName)) return "scss"; return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html"; }, @@ -73,7 +74,7 @@ export default Ember.Component.extend({ addField(name) { if (!name) return; - name = name.replace(/\W/g, ""); + name = name.replace(/[^a-zA-Z0-9-_/]/g, ""); this.get("theme").setField(this.get("currentTargetName"), name, ""); this.setProperties({ newFieldName: "", addingField: false }); this.fieldAdded(this.get("currentTargetName"), name); diff --git a/app/assets/javascripts/admin/models/theme.js.es6 b/app/assets/javascripts/admin/models/theme.js.es6 index d72ac47e4bc..0e2ff373f63 100644 --- a/app/assets/javascripts/admin/models/theme.js.es6 +++ b/app/assets/javascripts/admin/models/theme.js.es6 @@ -27,6 +27,13 @@ const Theme = RestModel.extend({ icon: "globe", advanced: true, customNames: true + }, + { + id: 5, + name: "extra_scss", + icon: "paint-brush", + advanced: true, + customNames: true } ].map(target => { target["edited"] = this.hasEdited(target.name); @@ -46,6 +53,14 @@ const Theme = RestModel.extend({ "footer" ]; + const scss_fields = (this.get("theme_fields") || []) + .filter(f => f.target === "extra_scss" && f.name !== "") + .map(f => f.name); + + if (scss_fields.length < 1) { + scss_fields.push("importable_scss"); + } + return { common: [...common, "embedded_scss"], desktop: common, @@ -56,7 +71,8 @@ const Theme = RestModel.extend({ ...(this.get("theme_fields") || []) .filter(f => f.target === "translations" && f.name !== "en") .map(f => f.name) - ] + ], + extra_scss: scss_fields }; }, @@ -71,7 +87,7 @@ const Theme = RestModel.extend({ error: this.hasError(target, fieldName) }; - if (target === "translations") { + if (target === "translations" || target === "extra_scss") { field.translatedName = fieldName; } else { field.translatedName = I18n.t( diff --git a/app/jobs/onceoff/rebake_all_html_theme_fields.rb b/app/jobs/onceoff/rebake_all_html_theme_fields.rb deleted file mode 100644 index afdce8abc11..00000000000 --- a/app/jobs/onceoff/rebake_all_html_theme_fields.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Jobs - class RebakeAllHtmlThemeFields < Jobs::Onceoff - def execute_onceoff(args) - ThemeField.where(type_id: ThemeField.types[:html]).find_each do |theme_field| - theme_field.update(value_baked: nil) - end - - Theme.clear_cache! - end - end -end diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb index 363656c6426..895cc17b5e4 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -124,13 +124,14 @@ class RemoteTheme < ActiveRecord::Base end theme_info = RemoteTheme.extract_theme_info(importer) + updated_fields = [] theme_info["assets"]&.each do |name, relative_path| if path = importer.real_path(relative_path) new_path = "#{File.dirname(path)}/#{SecureRandom.hex}#{File.extname(path)}" File.rename(path, new_path) # OptimizedImage has strict file name restrictions, so rename temporarily upload = UploadCreator.new(File.open(new_path), File.basename(relative_path), for_theme: true).create_for(theme.user_id) - theme.set_field(target: :common, name: name, type: :theme_upload_var, upload_id: upload.id) + updated_fields << theme.set_field(target: :common, name: name, type: :theme_upload_var, upload_id: upload.id) end end @@ -144,9 +145,13 @@ class RemoteTheme < ActiveRecord::Base importer.all_files.each do |filename| next unless opts = ThemeField.opts_from_file_path(filename) value = importer[filename] - theme.set_field(opts.merge(value: value)) + updated_fields << theme.set_field(opts.merge(value: value)) end + # Destroy fields that no longer exist in the remote theme + field_ids_to_destroy = theme.theme_fields.pluck(:id) - updated_fields.map(&:id) + ThemeField.where(id: field_ids_to_destroy).destroy_all + if !skip_update self.remote_updated_at = Time.zone.now self.remote_version = importer.version diff --git a/app/models/theme.rb b/app/models/theme.rb index 7a2c3c1e477..f8c486a48c0 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -51,6 +51,10 @@ class Theme < ActiveRecord::Base Theme.expire_site_cache! if saved_change_to_user_selectable? || saved_change_to_name? + reload + settings_field&.ensure_baked! # Other fields require setting to be **baked** + theme_fields.each(&:ensure_baked!) + remove_from_cache! clear_cached_settings! ColorScheme.hex_cache.clear @@ -76,6 +80,8 @@ class Theme < ActiveRecord::Base Theme.expire_site_cache! ColorScheme.hex_cache.clear + CSP::Extension.clear_theme_extensions_cache! + SvgSprite.expire_cache end after_commit ->(theme) do @@ -224,7 +230,7 @@ class Theme < ActiveRecord::Base end def self.targets - @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4) + @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4, extra_scss: 5) end def self.lookup_target(target_id) @@ -267,15 +273,15 @@ class Theme < ActiveRecord::Base def self.list_baked_fields(theme_ids, target, name) target = target.to_sym - name = name.to_sym + name = name&.to_sym if target == :translations fields = ThemeField.find_first_locale_fields(theme_ids, I18n.fallbacks[name]) else fields = ThemeField.find_by_theme_ids(theme_ids) .where(target_id: [Theme.targets[target], Theme.targets[:common]]) - .where(name: name.to_s) - .order(:target_id) + fields = fields.where(name: name.to_s) unless name.nil? + fields = fields.order(:target_id) end fields.each(&:ensure_baked!) @@ -325,6 +331,7 @@ class Theme < ActiveRecord::Base changed_fields << field end end + field else theme_fields.build(target_id: target_id, value: value, name: name, type_id: type_id, upload_id: upload_id) if value.present? || upload_id.present? end diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index 5a1e36c46d6..c1fe2b7a030 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -7,11 +7,6 @@ class ThemeField < ActiveRecord::Base belongs_to :upload has_one :javascript_cache, dependent: :destroy - after_commit do |field| - SvgSprite.expire_cache if field.target_id == Theme.targets[:settings] - SvgSprite.expire_cache if field.name == SvgSprite.theme_sprite_variable_name - end - scope :find_by_theme_ids, ->(theme_ids) { return none unless theme_ids.present? @@ -221,18 +216,16 @@ class ThemeField < ActiveRecord::Base end self.error = errors.join("\n").presence - if !self.error && self.target_id == Theme.targets[:settings] - # when settings YAML changes, we need to re-transpile theme JS and CSS - theme.theme_fields.where.not(id: self.id).update_all(value_baked: nil) - end end def self.guess_type(name:, target:) - if html_fields.include?(name.to_s) + if basic_targets.include?(target.to_s) && html_fields.include?(name.to_s) types[:html] - elsif scss_fields.include?(name.to_s) + elsif basic_targets.include?(target.to_s) && scss_fields.include?(name.to_s) types[:scss] - elsif name.to_s == "yaml" || target.to_s == "translations" + elsif target.to_s == "extra_scss" + types[:scss] + elsif target.to_s == "settings" || target.to_s == "translations" types[:yaml] end end @@ -245,46 +238,93 @@ class ThemeField < ActiveRecord::Base @scss_fields ||= %w(scss embedded_scss) end + def self.basic_targets + @basic_targets ||= %w(common desktop mobile) + end + + def basic_html_field? + ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) && + ThemeField.html_fields.include?(self.name) + end + + def basic_scss_field? + ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) && + ThemeField.scss_fields.include?(self.name) + end + + def extra_scss_field? + Theme.targets[self.target_id] == :extra_scss + end + + def settings_field? + Theme.targets[:settings] == self.target_id + end + + def translation_field? + Theme.targets[:translations] == self.target_id + end + + def svg_sprite_field? + ThemeField.theme_var_type_ids.include?(self.type_id) && self.name == SvgSprite.theme_sprite_variable_name + end + def ensure_baked! - if ThemeField.html_fields.include?(self.name) || translation = Theme.targets[:translations] == self.target_id - if !self.value_baked || compiler_version != COMPILER_VERSION - self.value_baked, self.error = translation ? process_translation : process_html(self.value) - self.error = nil unless self.error.present? - self.compiler_version = COMPILER_VERSION + needs_baking = !self.value_baked || compiler_version != COMPILER_VERSION + return unless needs_baking - if self.will_save_change_to_value_baked? || - self.will_save_change_to_compiler_version? || - self.will_save_change_to_error? - - self.update_columns(value_baked: value_baked, - compiler_version: compiler_version, - error: error) - end - end + if basic_html_field? || translation_field? + self.value_baked, self.error = translation_field? ? process_translation : process_html(self.value) + self.error = nil unless self.error.present? + self.compiler_version = COMPILER_VERSION + elsif basic_scss_field? + ensure_scss_compiles! + Stylesheet::Manager.clear_theme_cache! + elsif settings_field? + validate_yaml! + theme.clear_cached_settings! + CSP::Extension.clear_theme_extensions_cache! + SvgSprite.expire_cache + self.value_baked = "baked" + self.compiler_version = COMPILER_VERSION + elsif svg_sprite_field? + SvgSprite.expire_cache + self.error = nil + self.value_baked = "baked" + self.compiler_version = COMPILER_VERSION end + + if self.will_save_change_to_value_baked? || + self.will_save_change_to_compiler_version? || + self.will_save_change_to_error? + + self.update_columns(value_baked: value_baked, + compiler_version: compiler_version, + error: error) + end + end + + def compile_scss + Stylesheet::Compiler.compile("@import \"common/foundation/variables\"; @import \"theme_variables\"; @import \"theme_field\";", + "theme.scss", + theme_field: self.value.dup, + theme: self.theme + ) end def ensure_scss_compiles! - if ThemeField.scss_fields.include?(self.name) - begin - Stylesheet::Compiler.compile("@import \"common/foundation/variables\"; @import \"theme_variables\"; @import \"theme_field\";", - "theme.scss", - theme_field: self.value.dup, - theme: self.theme - ) - self.error = nil unless error.nil? - rescue SassC::SyntaxError => e - self.error = e.message unless self.destroyed? - end - - if will_save_change_to_error? - update_columns(error: self.error) - end + result = ["failed"] + begin + result = compile_scss + self.error = nil unless error.nil? + rescue SassC::SyntaxError => e + self.error = e.message unless self.destroyed? end + self.compiler_version = COMPILER_VERSION + self.value_baked = Digest::SHA1.hexdigest(result.join(",")) # We don't use the compiled CSS here, we just use it to invalidate the stylesheet cache end def target_name - Theme.targets.invert[target_id].to_s + Theme.targets[target_id].to_s end class ThemeFileMatcher @@ -311,7 +351,7 @@ class ThemeField < ActiveRecord::Base hash = {} OPTIONS.each do |option| plural = :"#{option}s" - hash[option] = @allowed_values[plural][0] if @allowed_values[plural].length == 1 + hash[option] = @allowed_values[plural][0] if @allowed_values[plural] && @allowed_values[plural].length == 1 hash[option] = match[option] if hash[option].nil? end hash @@ -337,6 +377,9 @@ class ThemeField < ActiveRecord::Base ThemeFileMatcher.new(regex: /^common\/embedded\.scss$/, targets: :common, names: "embedded_scss", types: :scss, canonical: -> (h) { "common/embedded.scss" }), + ThemeFileMatcher.new(regex: /^scss\/(?.+)\.scss$/, + targets: :extra_scss, names: nil, types: :scss, + canonical: -> (h) { "scss/#{h[:name]}.scss" }), ThemeFileMatcher.new(regex: /^settings\.ya?ml$/, names: "yaml", types: :yaml, targets: :settings, canonical: -> (h) { "settings.yml" }), @@ -370,24 +413,33 @@ class ThemeField < ActiveRecord::Base nil end - before_save do - validate_yaml! + def dependent_fields + if extra_scss_field? + return theme.theme_fields.where(target_id: ThemeField.basic_targets.map { |t| Theme.targets[t.to_sym] }, + name: ThemeField.scss_fields) + elsif settings_field? + return theme.theme_fields.where(target_id: ThemeField.basic_targets.map { |t| Theme.targets[t.to_sym] }, + name: ThemeField.scss_fields + ThemeField.html_fields) + end + ThemeField.none + end + def invalidate_baked! + update_column(:value_baked, nil) + dependent_fields.update_all(value_baked: nil) + end + + before_save do if will_save_change_to_value? && !will_save_change_to_value_baked? self.value_baked = nil end end + after_save do + dependent_fields.each(&:invalidate_baked!) + end + after_commit do - unless destroyed? - ensure_baked! - ensure_scss_compiles! - theme.clear_cached_settings! - end - - Stylesheet::Manager.clear_theme_cache! if self.name.include?("scss") - CSP::Extension.clear_theme_extensions_cache! if name == 'yaml' - # TODO message for mobile vs desktop MessageBus.publish "/header-change/#{theme.id}", self.value if theme && self.name == "header" MessageBus.publish "/footer-change/#{theme.id}", self.value if theme && self.name == "footer" diff --git a/app/models/theme_setting.rb b/app/models/theme_setting.rb index bb58c0e167c..b961dc1a65d 100644 --- a/app/models/theme_setting.rb +++ b/app/models/theme_setting.rb @@ -5,13 +5,12 @@ class ThemeSetting < ActiveRecord::Base validates :data_type, numericality: { only_integer: true } validates :name, length: { maximum: 255 } - after_save do - theme.clear_cached_settings! - theme.remove_from_cache! - theme.theme_fields.update_all(value_baked: nil) - theme.theme_settings.reload - SvgSprite.expire_cache if self.name.to_s.include?("_icon") - CSP::Extension.clear_theme_extensions_cache! if name.to_s == CSP::Extension::THEME_SETTING + after_save :clear_settings_cache + after_destroy :clear_settings_cache + + def clear_settings_cache + # All necessary caches will be cleared on next ensure_baked! + theme.settings_field&.invalidate_baked! end def self.types diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 034482c71a8..e48bbae6be0 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3403,6 +3403,7 @@ en: mobile: "Mobile" settings: "Settings" translations: "Translations" + extra_scss: "Extra SCSS" preview: "Preview" show_advanced: "Show advanced fields" hide_advanced: "Hide advanced fields" diff --git a/lib/stylesheet/compiler.rb b/lib/stylesheet/compiler.rb index 1c2a165e3b5..3b05f70a2d3 100644 --- a/lib/stylesheet/compiler.rb +++ b/lib/stylesheet/compiler.rb @@ -9,7 +9,7 @@ module Stylesheet def self.compile_asset(asset, options = {}) if Importer.special_imports[asset.to_s] - filename = "theme.scss" + filename = "theme_#{options[:theme_id]}.scss" file = "@import \"common/foundation/variables\"; @import \"theme_variables\"; @import \"#{asset}\";" else filename = "#{asset}.scss" diff --git a/lib/stylesheet/importer.rb b/lib/stylesheet/importer.rb index 19d9676120f..2e19d7d229d 100644 --- a/lib/stylesheet/importer.rb +++ b/lib/stylesheet/importer.rb @@ -14,7 +14,7 @@ module Stylesheet end register_import "theme_field" do - Import.new("theme_field.scss", source: @theme_field) + Import.new("#{theme_dir}/theme_field.scss", source: @theme_field) end register_import "plugins" do @@ -119,14 +119,14 @@ module Stylesheet fields.map do |field| value = field.value if value.present? - filename = "#{field.theme.id}/#{field.target_name}-#{field.name}-#{field.theme.name.parameterize}.scss" - with_comment = <console.log(123)") } - - it 'extracts inline javascripts' do - theme_field.update_attributes(value_baked: 'need to be rebaked') - - described_class.new.execute_onceoff({}) - - theme_field.reload - expect(theme_field.value_baked).to include('theme-javascripts') - end -end diff --git a/spec/models/remote_theme_spec.rb b/spec/models/remote_theme_spec.rb index 42e85dc2625..eca58289ac6 100644 --- a/spec/models/remote_theme_spec.rb +++ b/spec/models/remote_theme_spec.rb @@ -9,7 +9,7 @@ describe RemoteTheme do `cd #{repo_dir} && git init . ` `cd #{repo_dir} && git config user.email 'someone@cool.com'` `cd #{repo_dir} && git config user.name 'The Cool One'` - `cd #{repo_dir} && mkdir desktop mobile common assets locales` + `cd #{repo_dir} && mkdir desktop mobile common assets locales scss` files.each do |name, data| File.write("#{repo_dir}/#{name}", data) `cd #{repo_dir} && git add #{name}` @@ -46,6 +46,7 @@ describe RemoteTheme do setup_git_repo( "about.json" => about_json, "desktop/desktop.scss" => scss_data, + "scss/file.scss" => ".class1{color:red}", "common/header.html" => "I AM HEADER", "common/random.html" => "I AM SILLY", "common/embedded.scss" => "EMBED", @@ -77,7 +78,7 @@ describe RemoteTheme do expect(remote.theme_version).to eq("1.0") expect(remote.minimum_discourse_version).to eq("1.0.0") - expect(@theme.theme_fields.length).to eq(6) + expect(@theme.theme_fields.length).to eq(7) mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] @@ -91,7 +92,7 @@ describe RemoteTheme do expect(mapped["4-en"]).to eq("sometranslations") - expect(mapped.length).to eq(6) + expect(mapped.length).to eq(7) expect(@theme.settings.length).to eq(1) expect(@theme.settings.first.value).to eq(true) @@ -112,6 +113,8 @@ describe RemoteTheme do `cd #{initial_repo} && git add settings.yml` File.delete("#{initial_repo}/settings.yaml") + File.delete("#{initial_repo}/scss/file.scss") + `cd #{initial_repo} && git commit -am "update"` time = Time.new('2001') @@ -122,7 +125,7 @@ describe RemoteTheme do expect(remote.remote_version).to eq(`cd #{initial_repo} && git rev-parse HEAD`.strip) remote.update_from_remote - @theme.save + @theme.save! @theme.reload scheme = ColorScheme.find_by(theme_id: @theme.id) @@ -132,6 +135,9 @@ describe RemoteTheme do mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] + # Scss file was deleted + expect(mapped["5-file"]).to eq(nil) + expect(mapped["0-header"]).to eq("I AM UPDATED") expect(mapped["1-scss"]).to eq(scss_data) diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb index a8309900d59..a68b4056cb2 100644 --- a/spec/models/theme_field_spec.rb +++ b/spec/models/theme_field_spec.rb @@ -29,6 +29,7 @@ describe ThemeField do it 'does not insert a script tag when there are no inline script' do theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "body_tag", value: '
new div
') + theme_field.ensure_baked! expect(theme_field.value_baked).to_not include('") expect(theme_field.value_baked).to include("external-script.js") expect(theme_field.value_baked).to include('") expect(field.javascript_cache.content).to include("Theme Transpilation Error:") field.update!(value: '') + field.ensure_baked! expect(field.error).to eq(nil) end @@ -102,8 +105,9 @@ HTML HTML - ThemeField.create!(theme_id: 1, target_id: 3, name: "yaml", value: "string_setting: \"test text \\\" 123!\"") + ThemeField.create!(theme_id: 1, target_id: 3, name: "yaml", value: "string_setting: \"test text \\\" 123!\"").ensure_baked! theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "head_tag", value: html) + theme_field.ensure_baked! javascript_cache = theme_field.javascript_cache expect(theme_field.value_baked).to include("") @@ -115,15 +119,33 @@ HTML it "correctly generates errors for transpiled css" do css = "body {" field = ThemeField.create!(theme_id: 1, target_id: 0, name: "scss", value: css) - field.reload + field.ensure_baked! expect(field.error).not_to eq(nil) field.value = "body {color: blue};" field.save! - field.reload + field.ensure_baked! expect(field.error).to eq(nil) end + it "allows importing scss files" do + theme = Fabricate(:theme) + main_field = theme.set_field(target: :common, name: :scss, value: ".class1{color: red}\n@import 'rootfile1';") + theme.set_field(target: :extra_scss, name: "rootfile1", value: ".class2{color:green}\n@import 'foldername/subfile1';") + theme.set_field(target: :extra_scss, name: "rootfile2", value: ".class3{color:green} ") + theme.set_field(target: :extra_scss, name: "foldername/subfile1", value: ".class4{color:yellow}\n@import 'subfile2';") + theme.set_field(target: :extra_scss, name: "foldername/subfile2", value: ".class5{color:yellow}\n@import '../rootfile2';") + + theme.save! + result = main_field.compile_scss[0] + + expect(result).to include(".class1") + expect(result).to include(".class2") + expect(result).to include(".class3") + expect(result).to include(".class4") + expect(result).to include(".class5") + end + def create_upload_theme_field!(name) ThemeField.create!( theme_id: 1, @@ -131,7 +153,7 @@ HTML value: "", type_id: ThemeField.types[:theme_upload_var], name: name, - ) + ).tap { |tf| tf.ensure_baked! } end it "ensures we don't use invalid SCSS variable names" do @@ -145,7 +167,7 @@ HTML def create_yaml_field(value) field = ThemeField.create!(theme_id: 1, target_id: Theme.targets[:settings], name: "yaml", value: value) - field.reload + field.ensure_baked! field end @@ -179,7 +201,7 @@ HTML field.value = "valid_setting: true" field.save! - field.reload + field.ensure_baked! expect(field.error).to eq(nil) end @@ -319,6 +341,7 @@ HTML HTML theme_field = ThemeField.create!(theme_id: theme.id, target_id: 0, name: "head_tag", value: html) + theme_field.ensure_baked! javascript_cache = theme_field.javascript_cache expect(javascript_cache.content).to include("inline discourse plugin") expect(javascript_cache.content).to include("theme_translations.#{theme.id}.") diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index 3b0b442e697..786f592feb1 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -238,6 +238,7 @@ HTML context "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! return f.value_baked, f.javascript_cache end @@ -307,6 +308,7 @@ HTML setting = theme.settings.find { |s| s.name == :font_size } setting.value = '30px' + theme.save! scss, _map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id) expect(scss).to include("font-size:30px") @@ -356,6 +358,7 @@ HTML setting = theme.settings.find { |s| s.name == :name } setting.value = 'bill' + theme.save! transpiled = <<~HTML (function() { @@ -428,6 +431,7 @@ HTML 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"] } @@ -485,6 +489,7 @@ HTML expect(cached_settings(theme.id)).to match(/\"boolean_setting\":true/) theme.settings.first.value = "false" + theme.save! expect(cached_settings(theme.id)).to match(/\"boolean_setting\":false/) child.set_field(target: :settings, name: "yaml", value: "integer_setting: 54")