FEATURE: backend support for user-selectable components

* FEATURE: backend support for user-selectable components

* fix problems with previewing default theme

* rename preview_key => preview_theme_id

* omit default theme from child themes dropdown and try a different fix

* cache & freeze stylesheets arrays
This commit is contained in:
Osama Sayegh 2018-08-08 07:46:34 +03:00 committed by Sam
parent aafff740d2
commit 0b7ed8ffaf
53 changed files with 737 additions and 355 deletions

View File

@ -1,4 +1,7 @@
import { default as computed } from "ember-addons/ember-computed-decorators";
import {
default as computed,
observes
} from "ember-addons/ember-computed-decorators";
import { url } from "discourse/lib/computed";
import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal";
@ -9,6 +12,18 @@ const THEME_UPLOAD_VAR = 2;
export default Ember.Controller.extend({
editRouteName: "adminCustomizeThemes.edit",
@observes("allowChildThemes")
setSelectedThemeId() {
const available = this.get("selectableChildThemes");
if (
!this.get("selectedChildThemeId") &&
available &&
available.length > 0
) {
this.set("selectedChildThemeId", available[0].get("id"));
}
},
@computed("model", "allThemes")
parentThemes(model, allThemes) {
let parents = allThemes.filter(theme =>
@ -64,16 +79,21 @@ export default Ember.Controller.extend({
let themes = [];
available.forEach(t => {
if (!childThemes || childThemes.indexOf(t) === -1) {
if (
(!childThemes || childThemes.indexOf(t) === -1) &&
Em.isEmpty(t.get("childThemes")) &&
!t.get("user_selectable") &&
!t.get("default")
) {
themes.push(t);
}
});
return themes.length === 0 ? null : themes;
},
@computed("allThemes", "allThemes.length", "model")
@computed("allThemes", "allThemes.length", "model", "parentThemes")
availableChildThemes(allThemes, count) {
if (count === 1) {
if (count === 1 || this.get("parentThemes")) {
return null;
}

View File

@ -1,5 +1,6 @@
import RestModel from "discourse/models/rest";
import { default as computed } from "ember-addons/ember-computed-decorators";
import { popupAjaxError } from "discourse/lib/ajax-error";
const THEME_UPLOAD_VAR = 2;
@ -150,7 +151,9 @@ const Theme = RestModel.extend({
saveChanges() {
const hash = this.getProperties.apply(this, arguments);
return this.save(hash).then(() => this.set("changed", false));
return this.save(hash)
.finally(() => this.set("changed", false))
.catch(popupAjaxError);
},
saveSettings(name, value) {

View File

@ -137,7 +137,7 @@
{{/unless}}
{{#if selectableChildThemes}}
<p>
{{combo-box content=selectableChildThemes value=selectedChildThemeId}}
{{combo-box filterable=true content=selectableChildThemes value=selectedChildThemeId}}
{{#d-button action="addChildTheme" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}}
</p>
{{/if}}

View File

@ -66,7 +66,7 @@ export default Ember.Controller.extend(PreferencesTabController, {
@observes("themeId")
themeIdChanged() {
const id = this.get("themeId");
previewTheme(id);
previewTheme([id]);
},
homeChanged() {

View File

@ -1,5 +1,5 @@
import DiscourseURL from "discourse/lib/url";
import { currentThemeId, refreshCSS } from "discourse/lib/theme-selector";
import { currentThemeIds, refreshCSS } from "discourse/lib/theme-selector";
// Use the message bus for live reloading of components for faster development.
export default {
@ -58,12 +58,16 @@ export default {
// Refresh if necessary
document.location.reload(true);
} else {
let themeId = currentThemeId();
const themeIds = currentThemeIds();
$("link").each(function() {
if (me.hasOwnProperty("theme_id") && me.new_href) {
let target = $(this).data("target");
if (me.theme_id === themeId && target === me.target) {
const target = $(this).data("target");
const themeId = $(this).data("theme-id");
if (
themeIds.indexOf(me.theme_id) !== -1 &&
target === me.target &&
(!themeId || themeId === me.theme_id)
) {
refreshCSS(this, null, me.new_href);
}
} else if (this.href.match(me.name) && (me.hash || me.new_href)) {

View File

@ -1,7 +1,7 @@
import { ajax } from "discourse/lib/ajax";
import deprecated from "discourse-common/lib/deprecated";
const keySelector = "meta[name=discourse_theme_id]";
const keySelector = "meta[name=discourse_theme_ids]";
export function currentThemeKey() {
if (console && console.warn && console.trace) {
@ -12,21 +12,26 @@ export function currentThemeKey() {
}
}
export function currentThemeId() {
let themeId = null;
let elem = _.first($(keySelector));
export function currentThemeIds() {
const themeIds = [];
const elem = _.first($(keySelector));
if (elem) {
themeId = elem.content;
if (_.isEmpty(themeId)) {
themeId = null;
} else {
themeId = parseInt(themeId);
}
elem.content.split(",").forEach(num => {
num = parseInt(num, 10);
if (!isNaN(num)) {
themeIds.push(num);
}
});
}
return themeId;
return themeIds;
}
export function currentThemeId() {
return currentThemeIds()[0];
}
export function setLocalTheme(ids, themeSeq) {
ids = ids.reject(id => !id);
if (ids && ids.length > 0) {
$.cookie("theme_ids", `${ids.join(",")}|${themeSeq}`, {
path: "/",
@ -76,23 +81,28 @@ export function refreshCSS(node, hash, newHref, options) {
$orig.data("copy", reloaded);
}
export function previewTheme(id) {
if (currentThemeId() !== id) {
export function previewTheme(ids = []) {
ids = ids.reject(id => !id);
if (!ids.includes(currentThemeId())) {
Discourse.set("assetVersion", "forceRefresh");
ajax(`/themes/assets/${id ? id : "default"}`).then(results => {
let elem = _.first($(keySelector));
if (elem) {
elem.content = id;
}
results.themes.forEach(theme => {
let node = $(`link[rel=stylesheet][data-target=${theme.target}]`)[0];
if (node) {
refreshCSS(node, null, theme.url, { force: true });
ajax(`/themes/assets/${ids.length > 0 ? ids.join("-") : "default"}`).then(
results => {
const elem = _.first($(keySelector));
if (elem) {
elem.content = ids.join(",");
}
});
});
results.themes.forEach(theme => {
const node = $(
`link[rel=stylesheet][data-target=${theme.target}]`
)[0];
if (node) {
refreshCSS(node, null, theme.new_href, { force: true });
}
});
}
);
}
}

View File

@ -182,11 +182,13 @@ class Admin::ThemesController < Admin::AdminController
log_theme_change(original_json, @theme)
format.json { render json: @theme, status: :ok }
else
format.json {
format.json do
error = @theme.errors.full_messages.join(", ").presence
error = I18n.t("themes.bad_color_scheme") if @theme.errors[:color_scheme].present?
error ||= I18n.t("themes.other_error")
error = @theme.errors[:color_scheme] ? I18n.t("themes.bad_color_scheme") : I18n.t("themes.other_error")
render json: { errors: [ error ] }, status: :unprocessable_entity
}
end
end
end
end

View File

@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base
include GlobalPath
include Hijack
attr_reader :theme_id
attr_reader :theme_ids
serialization_scope :guardian
@ -62,8 +62,8 @@ class ApplicationController < ActionController::Base
after_action :remember_theme_id
def remember_theme_id
if @theme_id
Stylesheet::Watcher.theme_id = @theme_id if defined? Stylesheet::Watcher
if @theme_ids.present?
Stylesheet::Watcher.theme_id = @theme_ids.first if defined? Stylesheet::Watcher
end
end
end
@ -331,28 +331,33 @@ class ApplicationController < ActionController::Base
resolve_safe_mode
return if request.env[NO_CUSTOM]
theme_id = request[:preview_theme_id]&.to_i
theme_ids = []
if preview_theme_id = request[:preview_theme_id]&.to_i
theme_ids << preview_theme_id
end
user_option = current_user&.user_option
unless theme_id
if theme_ids.blank?
ids, seq = cookies[:theme_ids]&.split("|")
ids = ids&.split(",")&.map(&:to_i)
if ids && ids.size > 0 && seq && seq.to_i == user_option&.theme_key_seq.to_i
theme_id = ids.first
if ids.present? && seq && seq.to_i == user_option&.theme_key_seq.to_i
theme_ids = ids if guardian.allow_themes?(ids)
end
end
theme_id ||= user_option&.theme_ids&.first
theme_ids = user_option&.theme_ids || [] if theme_ids.blank?
if theme_id && !guardian.allow_themes?(theme_id)
theme_id = nil
unless guardian.allow_themes?(theme_ids)
theme_ids = []
end
theme_id ||= SiteSetting.default_theme_id
theme_id = nil if theme_id.blank? || theme_id == -1
if theme_ids.blank? && SiteSetting.default_theme_id != -1
theme_ids << SiteSetting.default_theme_id
end
@theme_id = request.env[:resolved_theme_id] = theme_id
@theme_ids = request.env[:resolved_theme_ids] = theme_ids
end
def guardian
@ -502,10 +507,10 @@ class ApplicationController < ActionController::Base
target = view_context.mobile_view? ? :mobile : :desktop
data =
if @theme_id
if @theme_ids.present?
{
top: Theme.lookup_field(@theme_id, target, "after_header"),
footer: Theme.lookup_field(@theme_id, target, "footer")
top: Theme.lookup_field(@theme_ids, target, "after_header"),
footer: Theme.lookup_field(@theme_ids, target, "footer")
}
else
{}

View File

@ -29,7 +29,7 @@ class StylesheetsController < ApplicationController
# we hold of re-compilation till someone asks for asset
if target.include?("theme")
split_target, theme_id = target.split(/_(-?[0-9]+)/)
theme = Theme.find(theme_id) if theme_id
theme = Theme.find_by(id: theme_id) if theme_id.present?
else
split_target, color_scheme_id = target.split(/_(-?[0-9]+)/)
theme = Theme.find_by(color_scheme_id: color_scheme_id)

View File

@ -1,27 +1,26 @@
class ThemesController < ::ApplicationController
def assets
theme_id = params[:id].to_i
theme_ids = params[:ids].to_s.split("-").map(&:to_i)
if params[:id] == "default"
theme_id = nil
if params[:ids] == "default"
theme_ids = nil
else
raise Discourse::NotFound unless Theme.where(id: theme_id).exists?
raise Discourse::NotFound unless guardian.allow_themes?(theme_ids)
end
object = [:mobile, :desktop, :desktop_theme, :mobile_theme].map do |target|
link = Stylesheet::Manager.stylesheet_link_tag(target, 'all', params[:id])
if link
href = link.split(/["']/)[1]
if Rails.env.development?
href << (href.include?("?") ? "&" : "?")
href << SecureRandom.hex
end
{
target: target,
url: href
}
targets = view_context.mobile_view? ? [:mobile, :mobile_theme] : [:desktop, :desktop_theme]
targets << :admin if guardian.is_staff?
object = targets.map do |target|
Stylesheet::Manager.stylesheet_data(target, theme_ids).map do |hash|
return hash unless Rails.env.development?
dup_hash = hash.dup
dup_hash[:new_href] << (dup_hash[:new_href].include?("?") ? "&" : "?")
dup_hash[:new_href] << SecureRandom.hex
dup_hash
end
end.compact
end.flatten
render json: object.as_json
end

View File

@ -350,11 +350,11 @@ module ApplicationHelper
end
end
def theme_id
def theme_ids
if customization_disabled?
nil
else
request.env[:resolved_theme_id]
request.env[:resolved_theme_ids]
end
end
@ -378,17 +378,17 @@ module ApplicationHelper
end
def theme_lookup(name)
lookup = Theme.lookup_field(theme_id, mobile_view? ? :mobile : :desktop, name)
lookup = Theme.lookup_field(theme_ids, mobile_view? ? :mobile : :desktop, name)
lookup.html_safe if lookup
end
def discourse_stylesheet_link_tag(name, opts = {})
if opts.key?(:theme_id)
id = opts[:theme_id] unless customization_disabled?
if opts.key?(:theme_ids)
ids = opts[:theme_ids] unless customization_disabled?
else
id = theme_id
ids = theme_ids
end
Stylesheet::Manager.stylesheet_link_tag(name, 'all', id)
Stylesheet::Manager.stylesheet_link_tag(name, 'all', ids)
end
end

View File

@ -1,6 +1,24 @@
class ChildTheme < ActiveRecord::Base
belongs_to :parent_theme, class_name: 'Theme'
belongs_to :child_theme, class_name: 'Theme'
validate :child_validations
private
def child_validations
if ChildTheme.exists?(["parent_theme_id = ? OR child_theme_id = ?", child_theme_id, parent_theme_id])
errors.add(:base, I18n.t("themes.errors.no_multilevels_components"))
end
if Theme.exists?(id: child_theme_id, user_selectable: true)
errors.add(:base, I18n.t("themes.errors.component_no_user_selectable"))
end
if child_theme_id == SiteSetting.default_theme_id
errors.add(:base, I18n.t("themes.errors.component_no_default"))
end
end
end
# == Schema Information

View File

@ -241,12 +241,15 @@ class ColorScheme < ActiveRecord::Base
def publish_discourse_stylesheet
if self.id
themes = Theme.where(color_scheme_id: self.id).to_a
if themes.present?
theme_ids = Theme.where(color_scheme_id: self.id).pluck(:id)
if theme_ids.present?
Stylesheet::Manager.cache.clear
themes.each do |theme|
theme.notify_scheme_change(_clear_manager_cache = false)
end
Theme.notify_theme_change(
theme_ids,
with_scheme: true,
clear_manager_cache: false,
all_themes: true
)
end
end
end

View File

@ -20,6 +20,12 @@ class Theme < ActiveRecord::Base
has_many :color_schemes
belongs_to :remote_theme
validate :user_selectable_validation
scope :user_selectable, ->() {
where('user_selectable OR id = ?', SiteSetting.default_theme_id)
}
def notify_color_change(color)
changed_colors << color
end
@ -45,7 +51,6 @@ class Theme < ActiveRecord::Base
remove_from_cache!
clear_cached_settings!
notify_scheme_change if saved_change_to_color_scheme_id?
end
after_destroy do
@ -70,29 +75,37 @@ class Theme < ActiveRecord::Base
end
after_commit ->(theme) do
theme.notify_theme_change
end, on: :update
theme.notify_theme_change(with_scheme: theme.saved_change_to_color_scheme_id?)
end, on: [:create, :update]
def self.get_set_cache(key, &blk)
if val = @cache[key]
return val
end
@cache[key] = blk.call
end
def self.theme_ids
if ids = @cache["theme_ids"]
return ids
get_set_cache "theme_ids" do
Theme.pluck(:id)
end
@cache["theme_ids"] = Set.new(Theme.pluck(:id))
end
def self.user_theme_ids
if ids = @cache["user_theme_ids"]
return ids
get_set_cache "user_theme_ids" do
Theme.user_selectable.pluck(:id)
end
end
def self.components_for(theme_id)
get_set_cache "theme_components_for_#{theme_id}" do
ChildTheme.where(parent_theme_id: theme_id).distinct.pluck(:child_theme_id)
end
@cache["user_theme_ids"] = Set.new(
Theme
.where('user_selectable OR id = ?', SiteSetting.default_theme_id)
.pluck(:id)
)
end
def self.expire_site_cache!
Site.clear_anon_cache!
clear_cache!
ApplicationSerializer.expire_cache_fragment!("user_themes")
end
@ -101,7 +114,25 @@ class Theme < ActiveRecord::Base
expire_site_cache!
end
def self.transform_ids(ids, extend: true)
return [] if ids.blank?
ids.uniq!
parent = ids.first
components = ids[1..-1]
components.push(*components_for(parent)) if extend
components.sort!.uniq!
[parent, *components]
end
def set_default!
if component?
raise Discourse::InvalidParameters.new(
I18n.t("themes.errors.component_no_default")
)
end
SiteSetting.default_theme_id = id
Theme.expire_site_cache!
end
@ -110,22 +141,32 @@ class Theme < ActiveRecord::Base
SiteSetting.default_theme_id == id
end
def self.lookup_field(theme_id, target, field)
return if theme_id.blank?
def component?
ChildTheme.exists?(child_theme_id: id)
end
cache_key = "#{theme_id}:#{target}:#{field}:#{ThemeField::COMPILER_VERSION}"
def user_selectable_validation
if component? && user_selectable
errors.add(:base, I18n.t("themes.errors.component_no_user_selectable"))
end
end
def self.lookup_field(theme_ids, target, field)
return if theme_ids.blank?
theme_ids = [theme_ids] unless Array === theme_ids
theme_ids = transform_ids(theme_ids)
cache_key = "#{theme_ids.join(",")}:#{target}:#{field}:#{ThemeField::COMPILER_VERSION}"
lookup = @cache[cache_key]
return lookup.html_safe if lookup
target = target.to_sym
theme = find_by(id: theme_id)
val = theme.resolve_baked_field(target, field) if theme
val = resolve_baked_field(theme_ids, target, field)
(@cache[cache_key] = val || "").html_safe
end
def self.remove_from_cache!(themes = nil)
def self.remove_from_cache!
clear_cache!
end
@ -141,33 +182,32 @@ class Theme < ActiveRecord::Base
self.targets.invert[target_id]
end
def notify_scheme_change(clear_manager_cache = true)
Stylesheet::Manager.cache.clear if clear_manager_cache
message = refresh_message_for_targets(["desktop", "mobile", "admin"], self)
MessageBus.publish('/file-change', message)
end
def notify_theme_change
def self.notify_theme_change(theme_ids, with_scheme: false, clear_manager_cache: true, all_themes: false)
Stylesheet::Manager.clear_theme_cache!
targets = [:mobile_theme, :desktop_theme]
themes = [self] + dependant_themes
if with_scheme
targets.prepend(:desktop, :mobile, :admin)
Stylesheet::Manager.cache.clear if clear_manager_cache
end
if all_themes
message = theme_ids.map { |id| refresh_message_for_targets(targets, id) }.flatten
else
message = refresh_message_for_targets(targets, theme_ids).flatten
end
message = themes.map do |theme|
refresh_message_for_targets([:mobile_theme, :desktop_theme], theme)
end.compact.flatten
MessageBus.publish('/file-change', message)
end
def refresh_message_for_targets(targets, theme)
def notify_theme_change(with_scheme: false)
theme_ids = (dependant_themes&.pluck(:id) || []).unshift(self.id)
self.class.notify_theme_change(theme_ids, with_scheme: with_scheme)
end
def self.refresh_message_for_targets(targets, theme_ids)
targets.map do |target|
href = Stylesheet::Manager.stylesheet_href(target.to_sym, theme.id)
if href
{
target: target,
new_href: href,
theme_id: theme.id
}
end
Stylesheet::Manager.stylesheet_data(target.to_sym, theme_ids)
end
end
@ -180,48 +220,34 @@ class Theme < ActiveRecord::Base
end
def resolve_dependant_themes(direction)
select_field, where_field = nil
if direction == :up
select_field = "parent_theme_id"
join_field = "parent_theme_id"
where_field = "child_theme_id"
elsif direction == :down
select_field = "child_theme_id"
join_field = "child_theme_id"
where_field = "parent_theme_id"
else
raise "Unknown direction"
end
themes = []
return [] unless id
uniq = Set.new
uniq << id
Theme.joins("JOIN child_themes ON themes.id = child_themes.#{join_field}").where("#{where_field} = ?", id)
end
iterations = 0
added = [id]
def self.resolve_baked_field(theme_ids, target, name)
list_baked_fields(theme_ids, target, name).map { |f| f.value_baked || f.value }.join("\n")
end
while added.length > 0 && iterations < 5
def self.list_baked_fields(theme_ids, target, name)
target = target.to_sym
iterations += 1
fields = ThemeField.find_by_theme_ids(theme_ids)
.where(target_id: [Theme.targets[target], Theme.targets[:common]])
.where(name: name.to_s)
new_themes = Theme.where("id in (SELECT #{select_field}
FROM child_themes
WHERE #{where_field} in (?))", added).to_a
added = []
new_themes.each do |theme|
unless uniq.include?(theme.id)
added << theme.id
uniq << theme.id
themes << theme
end
end
end
themes
fields.each(&:ensure_baked!)
fields
end
def resolve_baked_field(target, name)
@ -229,22 +255,8 @@ class Theme < ActiveRecord::Base
end
def list_baked_fields(target, name)
target = target.to_sym
theme_ids = [self.id] + (included_themes.map(&:id) || [])
fields = ThemeField.where(target_id: [Theme.targets[target], Theme.targets[:common]])
.where(name: name.to_s)
.includes(:theme)
.joins("
JOIN (
SELECT #{theme_ids.map.with_index { |id, idx| "#{id} AS theme_id, #{idx} AS sort_column" }.join(" UNION ALL SELECT ")}
) as X ON X.theme_id = theme_fields.theme_id"
)
.order('sort_column, target_id')
fields.each(&:ensure_baked!)
fields
theme_ids = (included_themes&.pluck(:id) || []).unshift(self.id)
self.class.list_baked_fields(theme_ids, target, name)
end
def remove_from_cache!
@ -288,21 +300,23 @@ class Theme < ActiveRecord::Base
def all_theme_variables
fields = {}
([self] + (included_themes || [])).each do |theme|
theme&.theme_fields.each do |field|
next unless ThemeField.theme_var_type_ids.include?(field.type_id)
next if fields.key?(field.name)
fields[field.name] = field
end
ids = (included_themes&.pluck(:id) || []).unshift(self.id)
ThemeField.find_by_theme_ids(ids).where(type_id: ThemeField.theme_var_type_ids).each do |field|
next if fields.key?(field.name)
fields[field.name] = field
end
fields.values
end
def add_child_theme!(theme)
child_theme_relation.create!(child_theme_id: theme.id)
@included_themes = nil
child_themes.reload
save!
new_relation = child_theme_relation.new(child_theme_id: theme.id)
if new_relation.save
@included_themes = nil
child_themes.reload
save!
else
raise Discourse::InvalidParameters.new(new_relation.errors.full_messages.join(", "))
end
end
def settings

View File

@ -4,6 +4,17 @@ class ThemeField < ActiveRecord::Base
belongs_to :upload
scope :find_by_theme_ids, ->(theme_ids) {
return none unless theme_ids.present?
where(theme_id: theme_ids)
.joins(
"JOIN (
SELECT #{theme_ids.map.with_index { |id, idx| "#{id.to_i} AS theme_id, #{idx} AS sort_column" }.join(" UNION ALL SELECT ")}
) as X ON X.theme_id = theme_fields.theme_id")
.order("sort_column")
}
def self.types
@types ||= Enum.new(html: 0,
scss: 1,

View File

@ -8,6 +8,6 @@
<%= discourse_stylesheet_link_tag(:admin) %>
<%- end %>
<%- if theme_id %>
<%- if theme_ids.present? %>
<%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %>
<%- end %>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8">
<title><%= content_for?(:title) ? yield(:title) : SiteSetting.title %></title>
<meta name="description" content="<%= @description_meta || SiteSetting.site_description %>">
<meta name="discourse_theme_id" content="<%= theme_id %>">
<meta name="discourse_theme_ids" content="<%= theme_ids&.join(",") %>">
<meta name="discourse_current_homepage" content="<%= current_homepage %>">
<%= render partial: "layouts/head" %>
<%= discourse_csrf_tags %>

View File

@ -10,7 +10,7 @@
<%- else %>
<%= discourse_stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %>
<%- end %>
<%- if theme_id %>
<%- if theme_ids.present? %>
<%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %>
<%- end %>
<%= theme_lookup("head_tag") %>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<%= discourse_stylesheet_link_tag 'embed', theme_id: nil %>
<%= discourse_stylesheet_link_tag 'embed', theme_ids: nil %>
<%- unless customization_disabled? %>
<%= discourse_stylesheet_link_tag :embedded_theme %>
<%- end %>

View File

@ -1,6 +1,6 @@
<html>
<head>
<%= discourse_stylesheet_link_tag 'wizard', theme_id: nil %>
<%= discourse_stylesheet_link_tag 'wizard', theme_ids: nil %>
<%= render partial: "common/special_font_face" %>
<%= preload_script 'ember_jquery' %>
<%= preload_script 'wizard-vendor' %>

View File

@ -1,6 +1,6 @@
<html>
<head>
<%= discourse_stylesheet_link_tag :wizard, theme_id: nil %>
<%= discourse_stylesheet_link_tag :wizard, theme_ids: nil %>
<%= preload_script 'ember_jquery' %>
<%= preload_script 'wizard-vendor' %>
<%= preload_script 'wizard-application' %>

View File

@ -4,7 +4,7 @@
<title>QUnit Test Runner</title>
<%= stylesheet_link_tag "qunit" %>
<%= stylesheet_link_tag "test_helper" %>
<%= discourse_stylesheet_link_tag :wizard %>
<%= discourse_stylesheet_link_tag :wizard, theme_ids: nil %>
<%= javascript_include_tag "qunit" %>
<%= javascript_include_tag "wizard/test/test_helper" %>
<%= csrf_meta_tags %>

View File

@ -60,6 +60,10 @@ en:
bad_color_scheme: "Can not update theme, invalid color scheme"
other_error: "Something went wrong updating theme"
error_importing: "Error cloning git repository, access is denied or repository is not found"
errors:
component_no_user_selectable: "Theme components can't be user-selectable"
component_no_default: "Theme components can't be default theme"
no_multilevels_components: "Themes with child themes can't be child themes themselves"
settings_errors:
invalid_yaml: "Provided YAML is invalid."
data_type_not_a_number: "Setting `%{name}` type is unsupported. Supported types are `integer`, `bool`, `list` and `enum`"

View File

@ -814,7 +814,7 @@ Discourse::Application.routes.draw do
get "/safe-mode" => "safe_mode#index"
post "/safe-mode" => "safe_mode#enter", as: "safe_mode_enter"
get "/themes/assets/:id" => "themes#assets"
get "/themes/assets/:ids" => "themes#assets"
if Rails.env == "test" || Rails.env == "development"
get "/qunit" => "qunit#index"

View File

@ -0,0 +1,68 @@
class DisallowMultiLevelsThemeComponents < ActiveRecord::Migration[5.2]
def up
@handled = []
top_parents = DB.query("
SELECT parent_theme_id, child_theme_id
FROM child_themes
WHERE parent_theme_id NOT IN (SELECT child_theme_id FROM child_themes)
")
top_parents.each do |top_parent|
migrate_child(top_parent, top_parent)
end
if @handled.size > 0
execute("
DELETE FROM child_themes
WHERE parent_theme_id NOT IN (#{top_parents.map(&:parent_theme_id).join(", ")})
")
end
execute("
UPDATE themes
SET user_selectable = false
FROM child_themes
WHERE themes.id = child_themes.child_theme_id
AND themes.user_selectable = true
")
default = DB.query_single("SELECT value FROM site_settings WHERE name = 'default_theme_id'").first
if default
default_child = DB.query("SELECT 1 AS one FROM child_themes WHERE child_theme_id = ?", default.to_i).present?
execute("DELETE FROM site_settings WHERE name = 'default_theme_id'") if default_child
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
private
def migrate_child(parent, top_parent)
unless already_exists?(top_parent.parent_theme_id, parent.child_theme_id)
execute("
INSERT INTO child_themes (parent_theme_id, child_theme_id, created_at, updated_at)
VALUES (#{top_parent.parent_theme_id}, #{parent.child_theme_id}, now(), now())
")
end
@handled << [top_parent.parent_theme_id, parent.parent_theme_id, parent.child_theme_id]
children = DB.query("
SELECT parent_theme_id, child_theme_id
FROM child_themes
WHERE parent_theme_id = :child", child: parent.child_theme_id
)
children.each do |child|
unless @handled.include?([top_parent.parent_theme_id, child.parent_theme_id, child.child_theme_id])
migrate_child(child, top_parent)
end
end
end
def already_exists?(parent, child)
DB.query("SELECT 1 AS one FROM child_themes WHERE child_theme_id = :child AND parent_theme_id = :parent", child: child, parent: parent).present?
end
end

View File

@ -359,9 +359,15 @@ class Guardian
end
def allow_themes?(theme_ids)
theme_ids = [theme_ids] unless theme_ids.is_a?(Array)
allowed_ids = is_staff? ? Theme.theme_ids : Theme.user_theme_ids
(theme_ids - allowed_ids.to_a).empty?
if is_staff? && (theme_ids - Theme.theme_ids).blank?
return true
end
parent = theme_ids.first
components = theme_ids[1..-1] || []
Theme.user_theme_ids.include?(parent) &&
(components - Theme.components_for(parent)).empty?
end
private

View File

@ -66,16 +66,16 @@ module Middleware
end
def cache_key
@cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}|b=#{has_brotli?}|t=#{theme_id}"
@cache_key ||= "ANON_CACHE_#{@env["HTTP_ACCEPT"]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}|m=#{is_mobile?}|c=#{is_crawler?}|b=#{has_brotli?}|t=#{theme_ids.join(",")}"
end
def theme_id
def theme_ids
ids, _ = @request.cookies['theme_ids']&.split('|')
ids = ids&.split(",")&.map(&:to_i)
if ids && Guardian.new.allow_themes?(ids)
ids.first
Theme.transform_ids(ids)
else
nil
[]
end
end

View File

@ -8,6 +8,7 @@ class Stylesheet::Manager
CACHE_PATH ||= 'tmp/stylesheet-cache'
MANIFEST_DIR ||= "#{Rails.root}/tmp/cache/assets/#{Rails.env}"
MANIFEST_FULL_PATH ||= "#{MANIFEST_DIR}/stylesheet-manifest"
THEME_REGEX ||= /_theme$/
@lock = Mutex.new
@ -19,38 +20,65 @@ class Stylesheet::Manager
cache.hash.keys.select { |k| k =~ /theme/ }.each { |k|cache.delete(k) }
end
def self.stylesheet_href(target = :desktop, theme_id = :missing)
href = stylesheet_link_tag(target, 'all', theme_id)
if href
href.split(/["']/)[1]
end
def self.stylesheet_data(target = :desktop, theme_ids = :missing)
stylesheet_details(target, "all", theme_ids)
end
def self.stylesheet_link_tag(target = :desktop, media = 'all', theme_id = :missing)
def self.stylesheet_link_tag(target = :desktop, media = 'all', theme_ids = :missing)
stylesheets = stylesheet_details(target, media, theme_ids)
stylesheets.map do |stylesheet|
href = stylesheet[:new_href]
theme_id = stylesheet[:theme_id]
data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : ""
%[<link href="#{href}" media="#{media}" rel="stylesheet" data-target="#{target}" #{data_theme_id}/>]
end.join("\n").html_safe
end
def self.stylesheet_details(target = :desktop, media = 'all', theme_ids = :missing)
if theme_ids == :missing
theme_ids = [SiteSetting.default_theme_id]
end
target = target.to_sym
if theme_id == :missing
theme_id = SiteSetting.default_theme_id
end
theme_ids = [theme_ids] unless Array === theme_ids
theme_ids = [theme_ids.first] unless target =~ THEME_REGEX
theme_ids = Theme.transform_ids(theme_ids, extend: false)
current_hostname = Discourse.current_hostname
cache_key = "#{target}_#{theme_id}_#{current_hostname}"
tag = cache[cache_key]
return tag.dup.html_safe if tag
array_cache_key = "array_themes_#{theme_ids.join(",")}_#{target}_#{current_hostname}"
stylesheets = cache[array_cache_key]
return stylesheets if stylesheets.present?
@lock.synchronize do
builder = self.new(target, theme_id)
if builder.is_theme? && !builder.theme
tag = ""
else
builder.compile unless File.exists?(builder.stylesheet_fullpath)
tag = %[<link href="#{builder.stylesheet_path(current_hostname)}" media="#{media}" rel="stylesheet" data-target="#{target}"/>]
end
stylesheets = []
theme_ids.each do |theme_id|
data = { target: target }
cache_key = "path_#{target}_#{theme_id}_#{current_hostname}"
href = cache[cache_key]
cache[cache_key] = tag
tag.dup.html_safe
unless href
builder = self.new(target, theme_id)
is_theme = builder.is_theme?
has_theme = builder.theme.present?
if is_theme && !has_theme
next
else
data[:theme_id] = builder.theme.id if has_theme && is_theme
builder.compile unless File.exists?(builder.stylesheet_fullpath)
href = builder.stylesheet_path(current_hostname)
end
cache[cache_key] = href
end
data[:theme_id] = theme_id if theme_id.present? && data[:theme_id].blank?
data[:new_href] = href
stylesheets << data
end
cache[array_cache_key] = stylesheets.freeze
stylesheets
end
end
@ -100,6 +128,10 @@ class Stylesheet::Manager
end.compact.max.to_i
end
def self.cache_fullpath
"#{Rails.root}/#{CACHE_PATH}"
end
def initialize(target = :desktop, theme_id)
@target = target
@theme_id = theme_id
@ -162,10 +194,6 @@ class Stylesheet::Manager
css
end
def self.cache_fullpath
"#{Rails.root}/#{CACHE_PATH}"
end
def cache_fullpath
self.class.cache_fullpath
end
@ -225,7 +253,7 @@ class Stylesheet::Manager
end
def is_theme?
!!(@target.to_s =~ /_theme$/)
!!(@target.to_s =~ THEME_REGEX)
end
# digest encodes the things that trigger a recompile
@ -240,7 +268,7 @@ class Stylesheet::Manager
end
def theme
@theme ||= (Theme.find_by(id: @theme_id) || :nil)
@theme ||= Theme.find_by(id: @theme_id) || :nil
@theme == :nil ? nil : @theme
end
@ -271,7 +299,16 @@ class Stylesheet::Manager
end
def settings_digest
Digest::SHA1.hexdigest((theme&.included_settings || {}).to_json)
fields = ThemeField.where(
name: "yaml",
type_id: ThemeField.types[:yaml],
theme_id: @theme_id
).pluck(:updated_at)
settings = ThemeSetting.where(theme_id: @theme_id).pluck(:updated_at)
timestamps = fields.concat(settings).map!(&:to_f).sort!.join(",")
Digest::SHA1.hexdigest(timestamps)
end
def uploads_digest

View File

@ -8,7 +8,10 @@ module Stylesheet
end
def self.theme_id
@theme_id || SiteSetting.default_theme_id
if @theme_id.blank? && SiteSetting.default_theme_id != -1
@theme_id = SiteSetting.default_theme_id
end
@theme_id
end
def self.watch(paths = nil)
@ -76,12 +79,8 @@ module Stylesheet
Stylesheet::Manager.cache.clear
message = ["desktop", "mobile", "admin"].map do |name|
{
target: name,
new_href: Stylesheet::Manager.stylesheet_href(name.to_sym),
theme_id: Stylesheet::Watcher.theme_id
}
end
Stylesheet::Manager.stylesheet_data(name.to_sym, Stylesheet::Watcher.theme_id)
end.flatten
MessageBus.publish '/file-change', message
end

View File

@ -2537,6 +2537,46 @@ describe Guardian do
end
end
describe "#allow_themes?" do
let(:theme) { Fabricate(:theme) }
let(:theme2) { Fabricate(:theme) }
it "allows staff to use any themes" do
expect(Guardian.new(moderator).allow_themes?([theme.id, theme2.id])).to eq(true)
expect(Guardian.new(admin).allow_themes?([theme.id, theme2.id])).to eq(true)
end
it "only allows normal users to use user-selectable themes or default theme" do
user_guardian = Guardian.new(user)
expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false)
expect(user_guardian.allow_themes?([theme.id])).to eq(false)
expect(user_guardian.allow_themes?([theme2.id])).to eq(false)
theme.set_default!
expect(user_guardian.allow_themes?([theme.id])).to eq(true)
expect(user_guardian.allow_themes?([theme2.id])).to eq(false)
expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false)
theme2.update!(user_selectable: true)
expect(user_guardian.allow_themes?([theme2.id])).to eq(true)
expect(user_guardian.allow_themes?([theme2.id, theme.id])).to eq(false)
end
it "allows child themes to be only used with their parent" do
user_guardian = Guardian.new(user)
theme.update!(user_selectable: true)
theme2.update!(user_selectable: true)
expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false)
theme2.update!(user_selectable: false)
theme.add_child_theme!(theme2)
expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(true)
expect(user_guardian.allow_themes?([theme2.id])).to eq(false)
end
end
describe 'can_wiki?' do
let(:post) { build(:post, created_at: 1.minute.ago) }

View File

@ -32,7 +32,7 @@ describe Middleware::AnonymousCache::Helper do
context "per theme cache" do
it "handles theme keys" do
theme = Theme.create(name: "test", user_id: -1, user_selectable: true)
theme = Fabricate(:theme, user_selectable: true)
with_bad_theme_key = new_helper("HTTP_COOKIE" => "theme_ids=abc").cache_key
with_no_theme_key = new_helper().cache_key

View File

@ -8,7 +8,7 @@ describe Stylesheet::Manager do
link = Stylesheet::Manager.stylesheet_link_tag(:embedded_theme)
expect(link).to eq("")
theme = Theme.create(name: "embedded", user_id: -1)
theme = Fabricate(:theme)
SiteSetting.default_theme_id = theme.id
link = Stylesheet::Manager.stylesheet_link_tag(:embedded_theme)
@ -16,10 +16,7 @@ describe Stylesheet::Manager do
end
it 'can correctly compile theme css' do
theme = Theme.new(
name: 'parent',
user_id: -1
)
theme = Fabricate(:theme)
theme.set_field(target: :common, name: "scss", value: ".common{.scss{color: red;}}")
theme.set_field(target: :desktop, name: "scss", value: ".desktop{.scss{color: red;}}")
@ -28,10 +25,7 @@ describe Stylesheet::Manager do
theme.save!
child_theme = Theme.new(
name: 'parent',
user_id: -1,
)
child_theme = Fabricate(:theme)
child_theme.set_field(target: :common, name: "scss", value: ".child_common{.scss{color: red;}}")
child_theme.set_field(target: :desktop, name: "scss", value: ".child_desktop{.scss{color: red;}}")
@ -72,10 +66,7 @@ describe Stylesheet::Manager do
it 'can correctly account for plugins in digest' do
theme = Theme.create!(
name: 'parent',
user_id: -1
)
theme = Fabricate(:theme)
manager = Stylesheet::Manager.new(:desktop_theme, theme.id)
digest1 = manager.digest
@ -92,10 +83,7 @@ describe Stylesheet::Manager do
let(:image2) { file_from_fixtures("logo-dev.png") }
it 'can correctly account for theme uploads in digest' do
theme = Theme.create!(
name: 'parent',
user_id: -1
)
theme = Fabricate(:theme)
upload = UploadCreator.new(image, "logo.png").create_for(-1)
field = ThemeField.create!(
@ -130,10 +118,7 @@ describe Stylesheet::Manager do
describe 'color_scheme_digest' do
it "changes with category background image" do
theme = Theme.new(
name: 'parent',
user_id: -1
)
theme = Fabricate(:theme)
category1 = Fabricate(:category, uploaded_background_id: 123, updated_at: 1.week.ago)
category2 = Fabricate(:category, uploaded_background_id: 456, updated_at: 2.days.ago)

View File

@ -4,7 +4,7 @@ require 'theme_settings_manager'
describe ThemeSettingsManager do
let(:theme_settings) do
theme = Theme.create!(name: "awesome theme", user_id: -1)
theme = Fabricate(:theme)
yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/valid_settings.yaml")
theme.set_field(target: :settings, name: "yaml", value: yaml)
theme.save!

View File

@ -194,6 +194,7 @@ describe Wizard::StepUpdater do
context "without an existing scheme" do
it "creates the scheme" do
ColorScheme.destroy_all
updater = wizard.create_updater('colors', theme_previews: 'Dark', allow_dark_light_selection: true)
updater.update
expect(updater.success?).to eq(true)

View File

@ -0,0 +1,4 @@
Fabricator(:theme) do
name { sequence(:name) { |i| "Cool theme #{i + 1}" } }
user
end

View File

@ -191,12 +191,12 @@ describe UserNotifications do
Fabricate(:color_scheme_color, name: 'header_background', hex: '1E1E1E'),
Fabricate(:color_scheme_color, name: 'tertiary', hex: '858585')
])
theme = Theme.create!(
name: 'my name',
user_id: Fabricate(:admin).id,
theme = Fabricate(:theme,
user_selectable: true,
user: Fabricate(:admin),
color_scheme_id: cs.id
)
theme.set_default!
html = subject.html_part.body.to_s

View File

@ -338,7 +338,7 @@ describe AdminDashboardData do
describe '#out_of_date_themes' do
let(:remote) { RemoteTheme.create!(remote_url: "https://github.com/org/testtheme") }
let!(:theme) { Theme.create!(remote_theme_id: remote.id, name: "Test< Theme", user_id: -1) }
let!(:theme) { Fabricate(:theme, remote_theme: remote, name: "Test< Theme") }
it "outputs correctly formatted html" do
remote.update!(local_version: "old version", remote_version: "new version", commits_behind: 2)

View File

@ -0,0 +1,43 @@
require 'rails_helper'
describe ChildTheme do
describe "validations" do
it "doesn't allow children to become parents or parents to become children" do
theme = Fabricate(:theme)
child = Fabricate(:theme)
child_theme = ChildTheme.new(parent_theme: theme, child_theme: child)
expect(child_theme.valid?).to eq(true)
child_theme.save!
grandchild = Fabricate(:theme)
child_theme = ChildTheme.new(parent_theme: child, child_theme: grandchild)
expect(child_theme.valid?).to eq(false)
expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.no_multilevels_components"))
grandparent = Fabricate(:theme)
child_theme = ChildTheme.new(parent_theme: grandparent, child_theme: theme)
expect(child_theme.valid?).to eq(false)
expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.no_multilevels_components"))
end
it "doesn't allow a user selectable theme to be a child" do
parent = Fabricate(:theme)
selectable_theme = Fabricate(:theme, user_selectable: true)
child_theme = ChildTheme.new(parent_theme: parent, child_theme: selectable_theme)
expect(child_theme.valid?).to eq(false)
expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.component_no_user_selectable"))
end
it "doesn't allow a default theme to be child" do
parent = Fabricate(:theme)
default = Fabricate(:theme)
default.set_default!
child_theme = ChildTheme.new(parent_theme: parent, child_theme: default)
expect(child_theme.valid?).to eq(false)
expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.component_no_default"))
end
end
end

View File

@ -10,15 +10,15 @@ describe ColorScheme do
it "correctly invalidates theme css when changed" do
scheme = ColorScheme.create_from_base(name: 'Bob')
theme = Theme.new(name: 'Amazing Theme', color_scheme_id: scheme.id, user_id: -1)
theme = Fabricate(:theme, color_scheme_id: scheme.id)
theme.set_field(name: :scss, target: :desktop, value: '.bob {color: $primary;}')
theme.save!
href = Stylesheet::Manager.stylesheet_href(:desktop_theme, theme.id)
href = Stylesheet::Manager.stylesheet_data(:desktop_theme, theme.id)[0][:new_href]
ColorSchemeRevisor.revise(scheme, colors: [{ name: 'primary', hex: 'bbb' }])
href2 = Stylesheet::Manager.stylesheet_href(:desktop_theme, theme.id)
href2 = Stylesheet::Manager.stylesheet_data(:desktop_theme, theme.id)[0][:new_href]
expect(href).not_to eq(href2)
end

View File

@ -189,7 +189,7 @@ describe RemoteTheme do
context ".out_of_date_themes" do
let(:remote) { RemoteTheme.create!(remote_url: "https://github.com/org/testtheme") }
let!(:theme) { Theme.create!(remote_theme_id: remote.id, name: "Test Theme", user_id: -1) }
let!(:theme) { Fabricate(:theme, remote_theme: remote) }
it "finds out of date themes" do
remote.update!(local_version: "old version", remote_version: "new version", commits_behind: 2)

View File

@ -17,9 +17,9 @@ describe Site do
end
it "includes user themes and expires them as needed" do
default_theme = Theme.create!(user_id: -1, name: 'default')
default_theme = Fabricate(:theme)
SiteSetting.default_theme_id = default_theme.id
user_theme = Theme.create!(user_id: -1, name: 'user theme', user_selectable: true)
user_theme = Fabricate(:theme, user_selectable: true)
anon_guardian = Guardian.new
user_guardian = Guardian.new(Fabricate(:user))

View File

@ -7,6 +7,26 @@ describe ThemeField do
ThemeField.destroy_all
end
describe "scope: find_by_theme_ids" do
it "returns result in the specified order" do
theme = Fabricate(:theme)
theme2 = Fabricate(:theme)
theme3 = Fabricate(:theme)
(0..1).each do |num|
ThemeField.create!(theme: theme, target_id: num, name: "header", value: "<a>html</a>")
ThemeField.create!(theme: theme2, target_id: num, name: "header", value: "<a>html</a>")
ThemeField.create!(theme: theme3, target_id: num, name: "header", value: "<a>html</a>")
end
expect(ThemeField.find_by_theme_ids(
[theme3.id, theme.id, theme2.id]
).pluck(:theme_id)).to eq(
[theme3.id, theme3.id, theme.id, theme.id, theme2.id, theme2.id]
)
end
end
it "correctly generates errors for transpiled js" do
html = <<HTML
<script type="text/discourse-plugin" version="0.8">

View File

@ -19,15 +19,16 @@ describe Theme do
end
let :customization do
Theme.create!(customization_params)
Fabricate(:theme, customization_params)
end
let(:theme) { Fabricate(:theme, user: user) }
let(:child) { Fabricate(:theme, user: user) }
it 'can properly clean up color schemes' do
theme = Theme.create!(name: 'bob', user_id: -1)
scheme = ColorScheme.create!(theme_id: theme.id, name: 'test')
scheme2 = ColorScheme.create!(theme_id: theme.id, name: 'test2')
Theme.create!(name: 'bob', user_id: -1, color_scheme_id: scheme2.id)
Fabricate(:theme, color_scheme_id: scheme2.id)
theme.destroy!
scheme2.reload
@ -38,8 +39,6 @@ describe Theme do
end
it 'can support child themes' do
child = Theme.new(name: '2', user_id: user.id)
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")
@ -54,7 +53,7 @@ describe Theme do
expect(Theme.lookup_field(child.id, :mobile, :header)).to eq("Worldie\nMobile")
parent = Theme.new(name: '1', user_id: user.id)
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")
@ -68,18 +67,39 @@ describe Theme do
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)
expect(child.dependant_themes.length).to eq(1)
end
it "doesn't allow multi-level theme components" do
grandchild = Fabricate(:theme, user: user)
grandparent = Fabricate(:theme, user: user)
theme.add_child_theme!(child)
child.add_child_theme!(grandchild)
expect do
child.add_child_theme!(grandchild)
end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.no_multilevels_components"))
expect(grandchild.dependant_themes.length).to eq(2)
expect do
grandparent.add_child_theme!(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
theme.add_child_theme!(child)
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
theme.add_child_theme!(child)
expect do
child.set_default!
end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.component_no_default"))
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(target: :common, name: "head_tag", value: "<b>I am bold")
theme.save!
@ -95,7 +115,6 @@ describe Theme do
{{hello}}
</script>
HTML
theme = Theme.new(user_id: -1, name: "test")
theme.set_field(target: :common, name: "header", value: with_template)
theme.save!
@ -106,8 +125,6 @@ HTML
end
it 'should create body_tag_baked on demand if needed' do
theme = Theme.new(user_id: -1, name: "test")
theme.set_field(target: :common, name: :body_tag, value: "<b>test")
theme.save
@ -116,6 +133,41 @@ HTML
expect(Theme.lookup_field(theme.id, :desktop, :body_tag)).to match(/<b>test<\/b>/)
end
it 'can find fields for multiple themes' do
theme2 = Fabricate(:theme)
theme.set_field(target: :common, name: :body_tag, value: "<b>testtheme1</b>")
theme2.set_field(target: :common, name: :body_tag, value: "<b>theme2test</b>")
theme.save!
theme2.save!
field = Theme.lookup_field([theme.id, theme2.id], :desktop, :body_tag)
expect(field).to match(/<b>testtheme1<\/b>/)
expect(field).to match(/<b>theme2test<\/b>/)
end
describe ".transform_ids" do
it "adds the child themes of the parent" do
child = Fabricate(:theme, id: 97)
child2 = Fabricate(:theme, id: 96)
theme.add_child_theme!(child)
theme.add_child_theme!(child2)
expect(Theme.transform_ids([theme.id])).to eq([theme.id, child2.id, child.id])
expect(Theme.transform_ids([theme.id, 94, 90])).to eq([theme.id, 90, 94, child2.id, child.id])
end
it "doesn't insert children when extend is false" do
child = Fabricate(:theme, id: 97)
child2 = Fabricate(:theme, id: 96)
theme.add_child_theme!(child)
theme.add_child_theme!(child2)
expect(Theme.transform_ids([theme.id], extend: false)).to eq([theme.id])
expect(Theme.transform_ids([theme.id, 94, 90, 70, 70], extend: false)).to eq([theme.id, 70, 90, 94])
end
end
context "plugin api" do
def transpile(html)
f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: 1, name: "after_header", value: html)
@ -152,14 +204,12 @@ HTML
context 'theme vars' do
it 'works in parent theme' do
theme = Theme.new(name: 'theme', user_id: -1)
theme.set_field(target: :common, name: :scss, value: 'body {color: $magic; }')
theme.set_field(target: :common, name: :magic, value: 'red', type: :theme_var)
theme.set_field(target: :common, name: :not_red, value: 'red', type: :theme_var)
theme.save
parent_theme = Theme.new(name: 'parent theme', user_id: -1)
parent_theme = Fabricate(:theme)
parent_theme.set_field(target: :common, name: :scss, value: 'body {background-color: $not_red; }')
parent_theme.set_field(target: :common, name: :not_red, value: 'blue', type: :theme_var)
parent_theme.save
@ -171,7 +221,6 @@ HTML
end
it 'can generate scss based off theme vars' do
theme = Theme.new(name: 'theme', user_id: -1)
theme.set_field(target: :common, name: :scss, value: 'body {color: $magic; content: quote($content)}')
theme.set_field(target: :common, name: :magic, value: 'red', type: :theme_var)
theme.set_field(target: :common, name: :content, value: 'Sam\'s Test', type: :theme_var)
@ -187,7 +236,6 @@ HTML
end
it 'can handle uploads based of ThemeField' do
theme = Theme.new(name: 'theme', user_id: -1)
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)}')
@ -210,7 +258,6 @@ HTML
context "theme settings" do
it "allows values to be used in scss" do
theme = Theme.new(name: "awesome theme", user_id: -1)
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!
@ -227,7 +274,6 @@ HTML
end
it "allows values to be used in JS" do
theme = Theme.new(name: "awesome theme", user_id: -1)
theme.set_field(target: :settings, name: :yaml, value: "name: bob")
theme.set_field(target: :common, name: :after_header, value: '<script type="text/discourse-plugin" version="1.0">alert(settings.name); let a = ()=>{};</script>')
theme.save!
@ -263,26 +309,30 @@ HTML
it 'correctly caches theme ids' do
Theme.destroy_all
theme = Theme.create!(name: "bob", user_id: -1)
theme
theme2 = Fabricate(:theme)
expect(Theme.theme_ids).to eq(Set.new([theme.id]))
expect(Theme.user_theme_ids).to eq(Set.new([]))
expect(Theme.theme_ids).to contain_exactly(theme.id, theme2.id)
expect(Theme.user_theme_ids).to eq([])
theme.user_selectable = true
theme.save
theme.update!(user_selectable: true)
expect(Theme.user_theme_ids).to eq(Set.new([theme.id]))
expect(Theme.user_theme_ids).to contain_exactly(theme.id)
theme.user_selectable = false
theme.save
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 eq(Set.new([theme.id]))
expect(Theme.user_theme_ids).to contain_exactly(theme.id)
theme.destroy
theme2.destroy
expect(Theme.theme_ids).to eq(Set.new([]))
expect(Theme.user_theme_ids).to eq(Set.new([]))
expect(Theme.theme_ids).to eq([])
expect(Theme.user_theme_ids).to eq([])
end
it 'correctly caches user_themes template' do
@ -292,8 +342,7 @@ HTML
user_themes = JSON.parse(json)["user_themes"]
expect(user_themes).to eq([])
theme = Theme.create!(name: "bob", user_id: -1, user_selectable: true)
theme.save!
theme = Fabricate(:theme, name: "bob", user_selectable: true)
json = Site.json_for(guardian)
user_themes = JSON.parse(json)["user_themes"].map { |t| t["name"] }
@ -320,7 +369,6 @@ HTML
it 'handles settings cache correctly' do
Theme.destroy_all
theme = Theme.create!(name: "awesome theme", user_id: -1)
expect(cached_settings(theme.id)).to eq("{}")
theme.set_field(target: :settings, name: "yaml", value: "boolean_setting: true")
@ -330,7 +378,6 @@ HTML
theme.settings.first.value = "false"
expect(cached_settings(theme.id)).to match(/\"boolean_setting\":false/)
child = Theme.create!(name: "child theme", user_id: -1)
child.set_field(target: :settings, name: "yaml", value: "integer_setting: 54")
child.save!
@ -347,5 +394,4 @@ HTML
expect(json).not_to match(/\"integer_setting\":54/)
expect(json).to match(/\"boolean_setting\":false/)
end
end

View File

@ -30,7 +30,7 @@ describe Admin::StaffActionLogsController do
describe '#diff' do
it 'can generate diffs for theme changes' do
theme = Theme.new(user_id: -1, name: 'bob')
theme = Fabricate(:theme)
theme.set_field(target: :mobile, name: :scss, value: 'body {.up}')
theme.set_field(target: :common, name: :scss, value: 'omit-dupe')

View File

@ -49,7 +49,7 @@ describe Admin::ThemesController do
it 'can import a theme with an upload' do
upload = Fabricate(:upload)
theme = Theme.new(name: 'with-upload', user_id: -1)
theme = Fabricate(:theme)
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.save!
@ -93,7 +93,7 @@ describe Admin::ThemesController do
ColorScheme.destroy_all
Theme.destroy_all
theme = Theme.new(name: 'my name', user_id: -1)
theme = Fabricate(:theme)
theme.set_field(target: :common, name: :scss, value: '.body{color: black;}')
theme.set_field(target: :desktop, name: :after_header, value: '<b>test</b>')
@ -141,7 +141,7 @@ describe Admin::ThemesController do
end
describe '#update' do
let(:theme) { Theme.create(name: 'my name', user_id: -1) }
let(:theme) { Fabricate(:theme) }
it 'can change default theme' do
SiteSetting.default_theme_id = -1
@ -169,7 +169,7 @@ describe Admin::ThemesController do
theme.set_field(target: :common, name: :scss, value: '.body{color: black;}')
theme.save
child_theme = Theme.create(name: 'my name', user_id: -1)
child_theme = Fabricate(:theme)
upload = Fabricate(:upload)
@ -198,5 +198,17 @@ describe Admin::ThemesController do
expect(json["theme"]["child_themes"].length).to eq(1)
expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
end
it 'returns the right error message' do
parent = Fabricate(:theme)
parent.add_child_theme!(theme)
put "/admin/themes/#{theme.id}.json", params: {
theme: { default: true }
}
expect(response.status).to eq(400)
expect(JSON.parse(response.body)["errors"].first).to include(I18n.t("themes.errors.component_no_default"))
end
end
end

View File

@ -33,4 +33,74 @@ RSpec.describe ApplicationController do
end
end
describe "#handle_theme" do
let(:theme) { Fabricate(:theme, user_selectable: true) }
let(:theme2) { Fabricate(:theme, user_selectable: true) }
let(:user) { Fabricate(:user) }
let(:admin) { Fabricate(:admin) }
before do
sign_in(user)
end
it "selects the theme the user has selected" do
user.user_option.update_columns(theme_ids: [theme.id])
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme.id])
theme.update_attribute(:user_selectable, false)
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([SiteSetting.default_theme_id])
end
it "can be overridden with a cookie" do
user.user_option.update_columns(theme_ids: [theme.id])
cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq}"
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme2.id])
theme2.update!(user_selectable: false)
theme.add_child_theme!(theme2)
cookies['theme_ids'] = "#{theme.id},#{theme2.id}|#{user.user_option.theme_key_seq}"
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme.id, theme2.id])
end
it "falls back to the default theme when the user has no cookies or preferences" do
user.user_option.update_columns(theme_ids: [])
cookies["theme_ids"] = nil
theme2.set_default!
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme2.id])
end
it "can be overridden with preview_theme_id param" do
sign_in(admin)
cookies['theme_ids'] = "#{theme.id},#{theme2.id}|#{admin.user_option.theme_key_seq}"
get "/", params: { preview_theme_id: theme2.id }
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme2.id])
end
it "cookie can fail back to user if out of sync" do
user.user_option.update_columns(theme_ids: [theme.id])
cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq - 1}"
get "/"
expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme.id])
end
end
end

View File

@ -83,7 +83,7 @@ describe EmbedController do
end
it "includes CSS from embedded_scss field" do
theme = Theme.create!(name: "Awesome blog", user_id: -1)
theme = Fabricate(:theme)
theme.set_default!
ThemeField.create!(

View File

@ -27,7 +27,7 @@ describe StylesheetsController do
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)
theme = Fabricate(:theme, color_scheme_id: scheme.id)
builder = Stylesheet::Manager.new(:desktop, theme.id)
builder.compile

View File

@ -1218,48 +1218,6 @@ RSpec.describe TopicsController do
expect(response.headers['X-Robots-Tag']).to eq(nil)
end
describe "themes" do
let(:theme) { Theme.create!(user_id: -1, name: 'bob', user_selectable: true) }
let(:theme2) { Theme.create!(user_id: -1, name: 'bobbob', user_selectable: true) }
before do
sign_in(user)
end
it "selects the theme the user has selected" do
user.user_option.update_columns(theme_ids: [theme.id])
get "/t/#{topic.id}"
expect(response).to be_redirect
expect(controller.theme_id).to eq(theme.id)
theme.update_attribute(:user_selectable, false)
get "/t/#{topic.id}"
expect(response).to be_redirect
expect(controller.theme_id).not_to eq(theme.id)
end
it "can be overridden with a cookie" do
user.user_option.update_columns(theme_ids: [theme.id])
cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq}"
get "/t/#{topic.id}"
expect(response).to be_redirect
expect(controller.theme_id).to eq(theme2.id)
end
it "cookie can fail back to user if out of sync" do
user.user_option.update_columns(theme_ids: [theme.id])
cookies['theme_ids'] = "#{theme2.id}|#{user.user_option.theme_key_seq - 1}"
get "/t/#{topic.id}"
expect(response).to be_redirect
expect(controller.theme_id).to eq(theme.id)
end
end
it "doesn't store an incoming link when there's no referer" do
expect {
get "/t/#{topic.id}.json"

View File

@ -1440,7 +1440,7 @@ describe UsersController do
notification_level: TagUser.notification_levels[:watching]
).pluck(:tag_id)).to contain_exactly(tags[0].id, tags[1].id)
theme = Theme.create(name: "test", user_selectable: true, user_id: -1)
theme = Fabricate(:theme, user_selectable: true)
put "/u/#{user.username}.json", params: {
muted_usernames: "",

View File

@ -154,7 +154,7 @@ describe StaffActionLogger do
end
let :theme do
Theme.new(name: 'bob', user_id: -1)
Fabricate(:theme)
end
it "logs new site customizations" do
@ -188,7 +188,7 @@ describe StaffActionLogger do
end
it "creates a new UserHistory record" do
theme = Theme.new(name: 'Banana')
theme = Fabricate(:theme)
theme.set_field(target: :common, name: :scss, value: "body{margin: 10px;}")
log_record = logger.log_theme_destroy(theme)

View File

@ -478,7 +478,7 @@ describe UserMerger do
end
it "updates themes" do
theme = Theme.create!(name: 'my name', user_id: source_user.id)
theme = Fabricate(:theme, user: source_user)
merge_users!
expect(theme.reload.user_id).to eq(target_user.id)

View File

@ -82,7 +82,7 @@ describe UserUpdater do
updater = UserUpdater.new(acting_user, user)
date_of_birth = Time.zone.now
theme = Theme.create!(user_id: -1, name: "test", user_selectable: true)
theme = Fabricate(:theme, user_selectable: true)
seq = user.user_option.theme_key_seq