FEATURE: themes and components split

* FEATURE: themes and components split

* two seperate methods to switch theme type

* use strict equality operator
This commit is contained in:
Osama Sayegh 2018-08-24 04:30:00 +03:00 committed by Sam
parent 4a552fb967
commit e0cc29d658
29 changed files with 445 additions and 157 deletions

View File

@ -0,0 +1,4 @@
export default Ember.Component.extend({
classNames: ["themes-list"],
hasThemes: Ember.computed.gt("themes.length", 0)
});

View File

@ -53,11 +53,6 @@ export default Ember.Controller.extend({
return this.shouldShow("mobile"); return this.shouldShow("mobile");
}, },
@computed("onlyOverridden", "model.remote_theme")
showSettings() {
return false;
},
@observes("onlyOverridden") @observes("onlyOverridden")
onlyOverriddenChanged() { onlyOverriddenChanged() {
if (this.get("onlyOverridden")) { if (this.get("onlyOverridden")) {

View File

@ -8,6 +8,7 @@ import showModal from "discourse/lib/show-modal";
import ThemeSettings from "admin/models/theme-settings"; import ThemeSettings from "admin/models/theme-settings";
const THEME_UPLOAD_VAR = 2; const THEME_UPLOAD_VAR = 2;
const SETTINGS_TYPE_ID = 5;
export default Ember.Controller.extend({ export default Ember.Controller.extend({
editRouteName: "adminCustomizeThemes.edit", editRouteName: "adminCustomizeThemes.edit",
@ -24,8 +25,11 @@ export default Ember.Controller.extend({
} }
}, },
@computed("model", "allThemes") @computed("model", "allThemes", "model.component")
parentThemes(model, allThemes) { parentThemes(model, allThemes) {
if (!model.get("component")) {
return null;
}
let parents = allThemes.filter(theme => let parents = allThemes.filter(theme =>
_.contains(theme.get("childThemes"), model) _.contains(theme.get("childThemes"), model)
); );
@ -34,7 +38,9 @@ export default Ember.Controller.extend({
@computed("model.theme_fields.@each") @computed("model.theme_fields.@each")
hasEditedFields(fields) { hasEditedFields(fields) {
return fields.any(f => !Em.isBlank(f.value)); return fields.any(
f => !Em.isBlank(f.value) && f.type_id !== SETTINGS_TYPE_ID
);
}, },
@computed("model.theme_fields.@each") @computed("model.theme_fields.@each")
@ -72,36 +78,31 @@ export default Ember.Controller.extend({
"model", "model",
"allowChildThemes" "allowChildThemes"
) )
selectableChildThemes(available, childThemes, model, allowChildThemes) { selectableChildThemes(available, childThemes, allowChildThemes) {
if (!allowChildThemes && (!childThemes || childThemes.length === 0)) { if (!allowChildThemes && (!childThemes || childThemes.length === 0)) {
return null; return null;
} }
let themes = []; let themes = [];
available.forEach(t => { available.forEach(t => {
if ( if (!childThemes || childThemes.indexOf(t) === -1) {
(!childThemes || childThemes.indexOf(t) === -1) &&
Em.isEmpty(t.get("childThemes")) &&
!t.get("user_selectable") &&
!t.get("default")
) {
themes.push(t); themes.push(t);
} }
}); });
return themes.length === 0 ? null : themes; return themes.length === 0 ? null : themes;
}, },
@computed("allThemes", "allThemes.length", "model", "parentThemes") @computed("allThemes", "allThemes.length", "model.component", "model")
availableChildThemes(allThemes, count) { availableChildThemes(allThemes, count, component) {
if (count === 1 || this.get("parentThemes")) { if (count === 1 || component) {
return null; return null;
} }
let excludeIds = [this.get("model.id")]; const themeId = this.get("model.id");
let themes = []; let themes = [];
allThemes.forEach(theme => { allThemes.forEach(theme => {
if (excludeIds.indexOf(theme.get("id")) === -1) { if (themeId !== theme.get("id") && theme.get("component")) {
themes.push(theme); themes.push(theme);
} }
}); });
@ -109,6 +110,12 @@ export default Ember.Controller.extend({
return themes; return themes;
}, },
@computed("model.component")
switchKey(component) {
const type = component ? "component" : "theme";
return `admin.customize.theme.switch_${type}`;
},
@computed("model.settings") @computed("model.settings")
settings(settings) { settings(settings) {
return settings.map(setting => ThemeSettings.create(setting)); return settings.map(setting => ThemeSettings.create(setting));
@ -254,6 +261,33 @@ export default Ember.Controller.extend({
} }
} }
); );
},
switchType() {
return bootbox.confirm(
I18n.t(`${this.get("switchKey")}_alert`),
I18n.t("no_value"),
I18n.t("yes_value"),
result => {
if (result) {
const model = this.get("model");
model.set("component", !model.get("component"));
model
.saveChanges("component")
.then(() => {
this.set("colorSchemeId", null);
model.setProperties({
default: false,
color_scheme_id: null,
user_selectable: false,
child_themes: [],
childThemes: []
});
})
.catch(popupAjaxError);
}
}
);
} }
} }
}); });

View File

@ -1,14 +1,19 @@
import { default as computed } from "ember-addons/ember-computed-decorators"; import { default as computed } from "ember-addons/ember-computed-decorators";
export default Ember.Controller.extend({ export default Ember.Controller.extend({
@computed("model", "model.@each") @computed("model", "model.@each", "model.@each.component")
sortedThemes(themes) { fullThemes(themes) {
return _.sortBy(themes.content, t => { return _.sortBy(themes.filter(t => !t.get("component")), t => {
return [ return [
!t.get("default"), !t.get("default"),
!t.get("user_selectable"), !t.get("user_selectable"),
t.get("name").toLowerCase() t.get("name").toLowerCase()
]; ];
}); });
},
@computed("model", "model.@each", "model.@each.component")
childThemes(themes) {
return themes.filter(t => t.get("component"));
} }
}); });

View File

@ -0,0 +1,36 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { default as computed } from "ember-addons/ember-computed-decorators";
import { popupAjaxError } from "discourse/lib/ajax-error";
const COMPONENT = "component";
export default Ember.Controller.extend(ModalFunctionality, {
types: [
{ name: I18n.t("admin.customize.theme.theme"), value: "theme" },
{ name: I18n.t("admin.customize.theme.component"), value: COMPONENT }
],
selectedType: "theme",
name: I18n.t("admin.customize.new_style"),
themesController: Ember.inject.controller("adminCustomizeThemes"),
loading: false,
@computed("selectedType")
component(type) {
return type === COMPONENT;
},
actions: {
createTheme() {
this.set("loading", true);
const theme = this.store.createRecord("theme");
theme
.save({ name: this.get("name"), component: this.get("component") })
.then(() => {
this.get("themesController").send("addTheme", theme);
this.send("closeModal");
})
.catch(popupAjaxError)
.finally(() => this.set("loading", false));
}
}
});

View File

@ -1,3 +1,5 @@
import { scrollTop } from "discourse/mixins/scroll-top";
export default Ember.Route.extend({ export default Ember.Route.extend({
serialize(model) { serialize(model) {
return { theme_id: model.get("id") }; return { theme_id: model.get("id") };
@ -10,14 +12,37 @@ export default Ember.Route.extend({
}, },
setupController(controller, model) { setupController(controller, model) {
this._super(...arguments);
controller.set("model", model); controller.set("model", model);
const parentController = this.controllerFor("adminCustomizeThemes"); const parentController = this.controllerFor("adminCustomizeThemes");
parentController.set("editingTheme", false); parentController.set("editingTheme", false);
controller.set("allThemes", parentController.get("model")); controller.set("allThemes", parentController.get("model"));
this.handleHighlight(model);
controller.set( controller.set(
"colorSchemes", "colorSchemes",
parentController.get("model.extras.color_schemes") parentController.get("model.extras.color_schemes")
); );
controller.set("colorSchemeId", model.get("color_scheme_id")); controller.set("colorSchemeId", model.get("color_scheme_id"));
},
deactivate() {
this.handleHighlight();
},
handleHighlight(theme) {
this.get("controller.allThemes").forEach(t => t.set("active", false));
if (theme) {
theme.set("active", true);
}
},
actions: {
didTransition() {
scrollTop();
}
} }
}); });

View File

@ -1,5 +1,4 @@
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default Ember.Route.extend({ export default Ember.Route.extend({
model() { model() {
@ -22,16 +21,8 @@ export default Ember.Route.extend({
this.transitionTo("adminCustomizeThemes.show", theme.get("id")); this.transitionTo("adminCustomizeThemes.show", theme.get("id"));
}, },
newTheme(obj) { showCreateModal() {
obj = obj || { name: I18n.t("admin.customize.new_style") }; showModal("admin-create-theme", { admin: true });
const item = this.store.createRecord("theme");
item
.save(obj)
.then(() => {
this.send("addTheme", item);
})
.catch(popupAjaxError);
} }
} }
}); });

View File

@ -0,0 +1,26 @@
<div class="themes-list-header">
<b>{{I18n title}}</b>
</div>
<div class="themes-list-container">
{{#if hasThemes}}
{{#each themes as |theme|}}
<div class="themes-list-item {{if theme.active 'active' ''}}">
{{#link-to 'adminCustomizeThemes.show' theme replace=true}}
{{plugin-outlet name="admin-customize-themes-list-item" connectorTagName='span' args=(hash theme=theme)}}
{{theme.name}}
{{#if theme.user_selectable}}
{{d-icon "user"}}
{{/if}}
{{#if theme.default}}
{{d-icon "asterisk"}}
{{/if}}
{{/link-to}}
</div>
{{/each}}
{{else}}
<div class="themes-list-item">
<span class="empty">{{I18n "admin.customize.theme.empty"}}</span>
</div>
{{/if}}
</div>

View File

@ -31,14 +31,6 @@
{{/link-to}} {{/link-to}}
</li> </li>
{{/if}} {{/if}}
{{#if showSettings}}
<li class='theme-settings'>
{{#link-to 'adminCustomizeThemes.edit' model.id 'settings' fieldName replace=true}}
{{i18n 'admin.customize.theme.settings'}}
{{d-icon 'cog'}}
{{/link-to}}
</li>
{{/if}}
</ul> </ul>
<div class='show-overidden'> <div class='show-overidden'>
<label> <label>

View File

@ -22,32 +22,34 @@
{{#if parentThemes}} {{#if parentThemes}}
<h3>{{i18n "admin.customize.theme.component_of"}}</h3> <h3>{{i18n "admin.customize.theme.component_of"}}</h3>
<ul> <ul>
{{#each parentThemes as |theme|}} {{#each parentThemes as |theme|}}
<li>{{#link-to 'adminCustomizeThemes.show' theme replace=true}}{{theme.name}}{{/link-to}}</li> <li>{{#link-to 'adminCustomizeThemes.show' theme replace=true}}{{theme.name}}{{/link-to}}</li>
{{/each}} {{/each}}
</ul> </ul>
{{else}}
<p>
{{inline-edit-checkbox action="applyDefault" labelKey="admin.customize.theme.is_default" checked=model.default}}
{{inline-edit-checkbox action="applyUserSelectable" labelKey="admin.customize.theme.user_selectable" checked=model.user_selectable}}
</p>
<h3>{{i18n "admin.customize.theme.color_scheme"}}</h3>
<p>{{i18n "admin.customize.theme.color_scheme_select"}}</p>
<p>{{combo-box content=colorSchemes
filterable=true
value=colorSchemeId
icon="paint-brush"}}
{{#if colorSchemeChanged}}
{{d-button action="changeScheme" class="btn-primary btn-small submit-edit" icon="check"}}
{{d-button action="cancelChangeScheme" class="btn-small cancel-edit" icon="times"}}
{{/if}}
</p>
{{#link-to 'adminCustomize.colors' class="btn edit"}}{{i18n 'admin.customize.colors.edit'}}{{/link-to}}
{{/if}} {{/if}}
{{#unless model.component}}
<p>
{{inline-edit-checkbox action="applyDefault" labelKey="admin.customize.theme.is_default" checked=model.default}}
{{inline-edit-checkbox action="applyUserSelectable" labelKey="admin.customize.theme.user_selectable" checked=model.user_selectable}}
</p>
<h3>{{i18n "admin.customize.theme.color_scheme"}}</h3>
<p>{{i18n "admin.customize.theme.color_scheme_select"}}</p>
<p>{{combo-box content=colorSchemes
filterable=true
value=colorSchemeId
icon="paint-brush"}}
{{#if colorSchemeChanged}}
{{d-button action="changeScheme" class="btn-primary btn-small submit-edit" icon="check"}}
{{d-button action="cancelChangeScheme" class="btn-small cancel-edit" icon="times"}}
{{/if}}
</p>
{{#link-to 'adminCustomize.colors' class="btn edit"}}{{i18n 'admin.customize.colors.edit'}}{{/link-to}}
{{/unless}}
<h3>{{i18n "admin.customize.theme.css_html"}}</h3> <h3>{{i18n "admin.customize.theme.css_html"}}</h3>
{{#if hasEditedFields}} {{#if hasEditedFields}}
<p>{{i18n "admin.customize.theme.custom_sections"}}</p> <p>{{i18n "admin.customize.theme.custom_sections"}}</p>
@ -61,15 +63,18 @@
{{i18n "admin.customize.theme.edit_css_html_help"}} {{i18n "admin.customize.theme.edit_css_html_help"}}
</p> </p>
{{/if}} {{/if}}
<p> <p>
{{#if model.remote_theme}} {{#if model.remote_theme}}
{{#if model.remote_theme.commits_behind}} {{#if model.remote_theme.commits_behind}}
{{#d-button action="updateToLatest" icon="download" class='btn-primary'}}{{i18n "admin.customize.theme.update_to_latest"}}{{/d-button}} {{#d-button action="updateToLatest" icon="download" class='btn-primary'}}{{i18n "admin.customize.theme.update_to_latest"}}{{/d-button}}
{{else}} {{else}}
{{#d-button action="checkForThemeUpdates" icon="refresh"}}{{i18n "admin.customize.theme.check_for_updates"}}{{/d-button}} {{#d-button action="checkForThemeUpdates" icon="refresh"}}{{i18n "admin.customize.theme.check_for_updates"}}{{/d-button}}
{{/if}} {{/if}}
{{/if}} {{/if}}
{{#d-button action="editTheme" class="btn edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}} {{#d-button action="editTheme" class="btn edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}}
{{#if model.remote_theme}} {{#if model.remote_theme}}
<span class='status-message'> <span class='status-message'>
{{#if updatingRemote}} {{#if updatingRemote}}
@ -146,5 +151,6 @@
<a href='{{previewUrl}}' title="{{i18n 'admin.customize.explain_preview'}}" target='_blank' class='btn'>{{d-icon 'desktop'}}{{i18n 'admin.customize.theme.preview'}}</a> <a href='{{previewUrl}}' title="{{i18n 'admin.customize.explain_preview'}}" target='_blank' class='btn'>{{d-icon 'desktop'}}{{i18n 'admin.customize.theme.preview'}}</a>
<a class="btn export" target="_blank" href={{downloadUrl}}>{{d-icon "download"}} {{i18n 'admin.export_json.button_text'}}</a> <a class="btn export" target="_blank" href={{downloadUrl}}>{{d-icon "download"}} {{i18n 'admin.export_json.button_text'}}</a>
{{d-button action="switchType" label=switchKey icon="arrows-h" class="btn-danger"}}
{{d-button action="destroy" label="admin.customize.delete" icon="trash" class="btn-danger"}} {{d-button action="destroy" label="admin.customize.delete" icon="trash" class="btn-danger"}}
</div> </div>

View File

@ -1,25 +1,15 @@
{{#unless editingTheme}} {{#unless editingTheme}}
<div class='content-list'> <div class='content-list'>
<h3>{{i18n 'admin.customize.theme.long_title'}}</h3> <h3>{{i18n 'admin.customize.theme.long_title'}}</h3>
<ul>
{{#each sortedThemes as |theme|}}
<li>
{{#link-to 'adminCustomizeThemes.show' theme replace=true}}
{{plugin-outlet name="admin-customize-themes-list-item" connectorTagName='span' args=(hash theme=theme)}}
{{theme.name}}
{{#if theme.user_selectable}}
{{d-icon "user"}}
{{/if}}
{{#if theme.default}}
{{d-icon "asterisk"}}
{{/if}}
{{/link-to}}
</li>
{{/each}}
</ul>
{{d-button label="admin.customize.new" icon="plus" action="newTheme" class="btn-primary"}} <div class="create-actions">
{{d-button action="importModal" icon="upload" label="admin.customize.import"}} {{d-button label="admin.customize.new" icon="plus" action="showCreateModal" class="btn-primary"}}
{{d-button action="importModal" icon="upload" label="admin.customize.import"}}
</div>
{{themes-list themes=fullThemes title="admin.customize.theme.title"}}
{{themes-list themes=childThemes title="admin.customize.theme.components"}}
</div> </div>
{{/unless}} {{/unless}}
{{outlet}} {{outlet}}

View File

@ -0,0 +1,24 @@
{{#d-modal-body class="create-theme-modal" title="admin.customize.theme.modal_title"}}
<div class="input">
<span class="label">
{{I18n "admin.customize.theme.create_name"}}
</span>
<span class="control">
{{input value=name}}
</span>
</div>
<div class="input">
<span class="label">
{{I18n "admin.customize.theme.create_type"}}
</span>
<span class="control">
{{combo-box valueAttribute="value" content=types value=selectedType}}
</span>
</div>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button class="btn btn-primary" label="admin.customize.theme.create" action="createTheme" disabled=loading}}
{{d-modal-cancel close=(action "closeModal")}}
</div>

View File

@ -44,6 +44,16 @@
} }
} }
.create-theme-modal {
div.input {
margin-bottom: 12px;
.label {
width: 20%;
display: inline-block;
}
}
}
.admin-customize { .admin-customize {
h1 { h1 {
margin-bottom: 10px; margin-bottom: 10px;
@ -101,6 +111,62 @@
} }
} }
.create-actions {
margin-bottom: 10px;
}
.themes-list {
margin-bottom: 20px;
}
.themes-list-header {
font-size: $font-up-1;
padding: 10px;
background-color: $primary-low;
}
.themes-list-container {
max-height: 280px;
overflow-y: scroll;
&::-webkit-scrollbar-track {
border-radius: 10px;
background-color: $secondary;
}
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: $primary-low-mid;
}
.themes-list-item {
color: $primary;
border-bottom: 1px solid $primary-low;
display: flex;
&:hover {
background-color: $tertiary-low;
}
&.active {
color: $secondary;
font-weight: bold;
background-color: $tertiary;
}
a,
span.empty {
color: inherit;
width: 100%;
padding: 10px;
}
}
}
.theme.settings { .theme.settings {
.theme-setting { .theme-setting {
padding-bottom: 0; padding-bottom: 0;

View File

@ -95,13 +95,13 @@ class Admin::ThemesController < Admin::AdminController
end end
def index def index
@theme = Theme.order(:name).includes(:theme_fields, :remote_theme) @themes = Theme.order(:name).includes(:theme_fields, :remote_theme)
@color_schemes = ColorScheme.all.to_a @color_schemes = ColorScheme.all.to_a
light = ColorScheme.new(name: I18n.t("color_schemes.light")) light = ColorScheme.new(name: I18n.t("color_schemes.light"))
@color_schemes.unshift(light) @color_schemes.unshift(light)
payload = { payload = {
themes: ActiveModel::ArraySerializer.new(@theme, each_serializer: ThemeSerializer), themes: ActiveModel::ArraySerializer.new(@themes, each_serializer: ThemeSerializer),
extras: { extras: {
color_schemes: ActiveModel::ArraySerializer.new(@color_schemes, each_serializer: ColorSchemeSerializer) color_schemes: ActiveModel::ArraySerializer.new(@color_schemes, each_serializer: ColorSchemeSerializer)
} }
@ -116,7 +116,8 @@ class Admin::ThemesController < Admin::AdminController
@theme = Theme.new(name: theme_params[:name], @theme = Theme.new(name: theme_params[:name],
user_id: current_user.id, user_id: current_user.id,
user_selectable: theme_params[:user_selectable] || false, user_selectable: theme_params[:user_selectable] || false,
color_scheme_id: theme_params[:color_scheme_id]) color_scheme_id: theme_params[:color_scheme_id],
component: [true, "true"].include?(theme_params[:component]))
set_fields set_fields
respond_to do |format| respond_to do |format|
@ -155,11 +156,11 @@ class Admin::ThemesController < Admin::AdminController
Theme.where(id: expected).each do |theme| Theme.where(id: expected).each do |theme|
@theme.add_child_theme!(theme) @theme.add_child_theme!(theme)
end end
end end
set_fields set_fields
update_settings update_settings
handle_switch
save_remote = false save_remote = false
if params[:theme][:remote_check] if params[:theme][:remote_check]
@ -247,6 +248,7 @@ class Admin::ThemesController < Admin::AdminController
:color_scheme_id, :color_scheme_id,
:default, :default,
:user_selectable, :user_selectable,
:component,
settings: {}, settings: {},
theme_fields: [:name, :target, :value, :upload_id, :type_id], theme_fields: [:name, :target, :value, :upload_id, :type_id],
child_theme_ids: [] child_theme_ids: []
@ -280,4 +282,12 @@ class Admin::ThemesController < Admin::AdminController
StaffActionLogger.new(current_user).log_theme_change(old_record, new_record) StaffActionLogger.new(current_user).log_theme_change(old_record, new_record)
end end
def handle_switch
param = theme_params[:component]
if param.to_s == "false" && @theme.component?
@theme.switch_to_theme!
elsif param.to_s == "true" && !@theme.component?
@theme.switch_to_component!
end
end
end end

View File

@ -7,17 +7,12 @@ class ChildTheme < ActiveRecord::Base
private private
def child_validations def child_validations
if ChildTheme.exists?(["parent_theme_id = ? OR child_theme_id = ?", child_theme_id, parent_theme_id]) if Theme.where(
"(component IS true AND id = :parent) OR (component IS false AND id = :child)",
parent: parent_theme_id, child: child_theme_id
).exists?
errors.add(:base, I18n.t("themes.errors.no_multilevels_components")) errors.add(:base, I18n.t("themes.errors.no_multilevels_components"))
end 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
end end

View File

@ -40,7 +40,8 @@ class RemoteTheme < ActiveRecord::Base
importer.import! importer.import!
theme_info = JSON.parse(importer["about.json"]) theme_info = JSON.parse(importer["about.json"])
theme = Theme.new(user_id: user&.id || -1, name: theme_info["name"]) component = [true, "true"].include?(theme_info["component"])
theme = Theme.new(user_id: user&.id || -1, name: theme_info["name"], component: component)
remote_theme = new remote_theme = new
theme.remote_theme = remote_theme theme.remote_theme = remote_theme
@ -142,7 +143,7 @@ class RemoteTheme < ActiveRecord::Base
self.commits_behind = 0 self.commits_behind = 0
end end
update_theme_color_schemes(theme, theme_info["color_schemes"]) update_theme_color_schemes(theme, theme_info["color_schemes"]) unless theme.component
self self
ensure ensure

View File

@ -20,7 +20,7 @@ class Theme < ActiveRecord::Base
has_many :color_schemes has_many :color_schemes
belongs_to :remote_theme belongs_to :remote_theme
validate :user_selectable_validation validate :component_validations
scope :user_selectable, ->() { scope :user_selectable, ->() {
where('user_selectable OR id = ?', SiteSetting.default_theme_id) where('user_selectable OR id = ?', SiteSetting.default_theme_id)
@ -128,7 +128,7 @@ class Theme < ActiveRecord::Base
end end
def set_default! def set_default!
if component? if component
raise Discourse::InvalidParameters.new( raise Discourse::InvalidParameters.new(
I18n.t("themes.errors.component_no_default") I18n.t("themes.errors.component_no_default")
) )
@ -141,13 +141,36 @@ class Theme < ActiveRecord::Base
SiteSetting.default_theme_id == id SiteSetting.default_theme_id == id
end end
def component? def component_validations
ChildTheme.exists?(child_theme_id: id) return unless component
errors.add(:base, I18n.t("themes.errors.component_no_color_scheme")) if color_scheme_id.present?
errors.add(:base, I18n.t("themes.errors.component_no_user_selectable")) if user_selectable
errors.add(:base, I18n.t("themes.errors.component_no_default")) if default?
end end
def user_selectable_validation def switch_to_component!
if component? && user_selectable return if component
errors.add(:base, I18n.t("themes.errors.component_no_user_selectable"))
Theme.transaction do
self.component = true
self.color_scheme_id = nil
self.user_selectable = false
Theme.clear_default! if default?
ChildTheme.where("parent_theme_id = ?", id).destroy_all
self.save!
end
end
def switch_to_theme!
return unless component
Theme.transaction do
self.component = false
ChildTheme.where("child_theme_id = ?", id).destroy_all
self.save!
end end
end end

View File

@ -33,7 +33,7 @@ class ThemeFieldSerializer < ApplicationSerializer
end end
class ChildThemeSerializer < ApplicationSerializer class ChildThemeSerializer < ApplicationSerializer
attributes :id, :name, :created_at, :updated_at, :default attributes :id, :name, :created_at, :updated_at, :default, :component
def include_default? def include_default?
object.id == SiteSetting.default_theme_id object.id == SiteSetting.default_theme_id
@ -76,6 +76,10 @@ class ThemeSerializer < ChildThemeSerializer
def settings def settings
object.settings.map { |setting| ThemeSettingsSerializer.new(setting, root: false) } object.settings.map { |setting| ThemeSettingsSerializer.new(setting, root: false) }
end end
def include_child_themes?
!object.component?
end
end end
class ThemeFieldWithEmbeddedUploadsSerializer < ThemeFieldSerializer class ThemeFieldWithEmbeddedUploadsSerializer < ThemeFieldSerializer

View File

@ -3192,9 +3192,16 @@ en:
revert_confirm: "Are you sure you want to revert your changes?" revert_confirm: "Are you sure you want to revert your changes?"
theme: theme:
theme: "Theme"
component: "Component"
components: "Components"
import_theme: "Import Theme" import_theme: "Import Theme"
customize_desc: "Customize:" customize_desc: "Customize:"
title: "Themes" title: "Themes"
modal_title: "Create Theme"
create: "Create"
create_type: "Type:"
create_name: "Name:"
long_title: "Amend colors, CSS and HTML contents of your site" long_title: "Amend colors, CSS and HTML contents of your site"
edit: "Edit" edit: "Edit"
edit_confirm: "This is a remote theme, if you edit CSS/HTML your changes will be erased next time you update the theme." edit_confirm: "This is a remote theme, if you edit CSS/HTML your changes will be erased next time you update the theme."
@ -3209,6 +3216,10 @@ en:
color_scheme_select: "Select colors to be used by theme" color_scheme_select: "Select colors to be used by theme"
custom_sections: "Custom sections:" custom_sections: "Custom sections:"
theme_components: "Theme Components" theme_components: "Theme Components"
switch_component: "Make theme"
switch_component_alert: "Are you sure you want to convert this component to theme? This will make it an independant theme and it will be removed as a child from all themes."
switch_theme: "Make component"
switch_theme_alert: "Are you sure you want to convert this theme to component? It will be removed as a parent from all components."
uploads: "Uploads" uploads: "Uploads"
no_uploads: "You can upload assets associated with your theme such as fonts and images" no_uploads: "You can upload assets associated with your theme such as fonts and images"
add_upload: "Add Upload" add_upload: "Add Upload"
@ -3231,7 +3242,7 @@ en:
public_key: "Grant the following public key access to the repo:" public_key: "Grant the following public key access to the repo:"
about_theme: "About Theme" about_theme: "About Theme"
license: "License" license: "License"
component_of: "Theme is a component of:" component_of: "Component of:"
update_to_latest: "Update to Latest" update_to_latest: "Update to Latest"
check_for_updates: "Check for Updates" check_for_updates: "Check for Updates"
updating: "Updating..." updating: "Updating..."
@ -3239,6 +3250,7 @@ en:
add: "Add" add: "Add"
theme_settings: "Theme Settings" theme_settings: "Theme Settings"
no_settings: "This theme has no settings." no_settings: "This theme has no settings."
empty: "No items"
commits_behind: commits_behind:
one: "Theme is 1 commit behind!" one: "Theme is 1 commit behind!"
other: "Theme is {{count}} commits behind!" other: "Theme is {{count}} commits behind!"

View File

@ -63,6 +63,7 @@ en:
errors: errors:
component_no_user_selectable: "Theme components can't be user-selectable" component_no_user_selectable: "Theme components can't be user-selectable"
component_no_default: "Theme components can't be default theme" component_no_default: "Theme components can't be default theme"
component_no_color_scheme: "Theme components can't have color scheme"
no_multilevels_components: "Themes with child themes can't be child themes themselves" no_multilevels_components: "Themes with child themes can't be child themes themselves"
settings_errors: settings_errors:
invalid_yaml: "Provided YAML is invalid." invalid_yaml: "Provided YAML is invalid."

View File

@ -0,0 +1,26 @@
class AddComponentToThemes < ActiveRecord::Migration[5.2]
def up
add_column :themes, :component, :boolean, null: false, default: false
execute("
UPDATE themes
SET component = true, color_scheme_id = NULL, user_selectable = false
WHERE id IN (SELECT child_theme_id FROM child_themes)
")
execute("
UPDATE site_settings
SET value = -1
WHERE name = 'default_theme_id' AND value::integer IN (SELECT id FROM themes WHERE component)
")
execute("
DELETE FROM child_themes
WHERE parent_theme_id IN (SELECT id FROM themes WHERE component)
")
end
def down
remove_column :themes, :component
end
end

View File

@ -2609,7 +2609,7 @@ describe Guardian do
theme2.update!(user_selectable: true) theme2.update!(user_selectable: true)
expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false) expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false)
theme2.update!(user_selectable: false) theme2.update!(user_selectable: false, component: true)
theme.add_child_theme!(theme2) theme.add_child_theme!(theme2)
expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(true) expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(true)
expect(user_guardian.allow_themes?([theme2.id])).to eq(false) expect(user_guardian.allow_themes?([theme2.id])).to eq(false)

View File

@ -25,7 +25,7 @@ describe Stylesheet::Manager do
theme.save! theme.save!
child_theme = Fabricate(:theme) child_theme = Fabricate(:theme, component: true)
child_theme.set_field(target: :common, name: "scss", value: ".child_common{.scss{color: red;}}") 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;}}") child_theme.set_field(target: :desktop, name: "scss", value: ".child_desktop{.scss{color: red;}}")

View File

@ -4,13 +4,13 @@ describe ChildTheme do
describe "validations" do describe "validations" do
it "doesn't allow children to become parents or parents to become children" do it "doesn't allow children to become parents or parents to become children" do
theme = Fabricate(:theme) theme = Fabricate(:theme)
child = Fabricate(:theme) child = Fabricate(:theme, component: true)
child_theme = ChildTheme.new(parent_theme: theme, child_theme: child) child_theme = ChildTheme.new(parent_theme: theme, child_theme: child)
expect(child_theme.valid?).to eq(true) expect(child_theme.valid?).to eq(true)
child_theme.save! child_theme.save!
grandchild = Fabricate(:theme) grandchild = Fabricate(:theme, component: true)
child_theme = ChildTheme.new(parent_theme: child, child_theme: grandchild) child_theme = ChildTheme.new(parent_theme: child, child_theme: grandchild)
expect(child_theme.valid?).to eq(false) expect(child_theme.valid?).to eq(false)
expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.no_multilevels_components")) expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.no_multilevels_components"))
@ -20,24 +20,5 @@ describe ChildTheme do
expect(child_theme.valid?).to eq(false) expect(child_theme.valid?).to eq(false)
expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.no_multilevels_components")) expect(child_theme.errors.full_messages).to contain_exactly(I18n.t("themes.errors.no_multilevels_components"))
end 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
end end

View File

@ -23,7 +23,7 @@ describe Theme do
end end
let(:theme) { Fabricate(:theme, user: user) } let(:theme) { Fabricate(:theme, user: user) }
let(:child) { Fabricate(:theme, user: user) } let(:child) { Fabricate(:theme, user: user, component: true) }
it 'can properly clean up color schemes' do it 'can properly clean up color schemes' do
scheme = ColorScheme.create!(theme_id: theme.id, name: 'test') scheme = ColorScheme.create!(theme_id: theme.id, name: 'test')
scheme2 = ColorScheme.create!(theme_id: theme.id, name: 'test2') scheme2 = ColorScheme.create!(theme_id: theme.id, name: 'test2')
@ -76,7 +76,6 @@ describe Theme do
grandchild = Fabricate(:theme, user: user) grandchild = Fabricate(:theme, user: user)
grandparent = Fabricate(:theme, user: user) grandparent = Fabricate(:theme, user: user)
theme.add_child_theme!(child)
expect do expect do
child.add_child_theme!(grandchild) child.add_child_theme!(grandchild)
end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.no_multilevels_components")) end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.no_multilevels_components"))
@ -87,18 +86,22 @@ describe Theme do
end end
it "doesn't allow a child to be user selectable" do it "doesn't allow a child to be user selectable" do
theme.add_child_theme!(child)
child.update(user_selectable: true) child.update(user_selectable: true)
expect(child.errors.full_messages).to contain_exactly(I18n.t("themes.errors.component_no_user_selectable")) expect(child.errors.full_messages).to contain_exactly(I18n.t("themes.errors.component_no_user_selectable"))
end end
it "doesn't allow a child to be set as the default theme" do it "doesn't allow a child to be set as the default theme" do
theme.add_child_theme!(child)
expect do expect do
child.set_default! child.set_default!
end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.component_no_default")) end.to raise_error(Discourse::InvalidParameters, I18n.t("themes.errors.component_no_default"))
end 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 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.set_field(target: :common, name: "head_tag", value: "<b>I am bold")
theme.save! theme.save!
@ -146,14 +149,49 @@ HTML
expect(field).to match(/<b>theme2test<\/b>/) expect(field).to match(/<b>theme2test<\/b>/)
end end
describe ".transform_ids" do describe "#switch_to_component!" do
it "adds the child themes of the parent" do it "correctly converts a theme to component" do
child = Fabricate(:theme) theme.add_child_theme!(child)
child2 = Fabricate(:theme) scheme = ColorScheme.create!(name: 'test')
sorted = [child.id, child2.id].sort 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_child_theme!(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!(:child) { Fabricate(:theme, component: true) }
let!(:child2) { Fabricate(:theme, component: true) }
before do
theme.add_child_theme!(child) theme.add_child_theme!(child)
theme.add_child_theme!(child2) theme.add_child_theme!(child2)
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]) expect(Theme.transform_ids([theme.id])).to eq([theme.id, *sorted])
fake_id = [child.id, child2.id, theme.id].min - 5 fake_id = [child.id, child2.id, theme.id].min - 5
@ -164,12 +202,6 @@ HTML
end end
it "doesn't insert children when extend is false" do it "doesn't insert children when extend is false" do
child = Fabricate(:theme)
child2 = Fabricate(:theme)
theme.add_child_theme!(child)
theme.add_child_theme!(child2)
fake_id = theme.id + 1 fake_id = theme.id + 1
fake_id2 = fake_id + 2 fake_id2 = fake_id + 2
fake_id3 = fake_id2 + 3 fake_id3 = fake_id2 + 3
@ -219,6 +251,7 @@ HTML
theme.set_field(target: :common, name: :scss, value: 'body {color: $magic; }') 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: :magic, value: 'red', type: :theme_var)
theme.set_field(target: :common, name: :not_red, value: 'red', type: :theme_var) theme.set_field(target: :common, name: :not_red, value: 'red', type: :theme_var)
theme.component = true
theme.save theme.save
parent_theme = Fabricate(:theme) parent_theme = Fabricate(:theme)

View File

@ -169,7 +169,7 @@ describe Admin::ThemesController do
theme.set_field(target: :common, name: :scss, value: '.body{color: black;}') theme.set_field(target: :common, name: :scss, value: '.body{color: black;}')
theme.save theme.save
child_theme = Fabricate(:theme) child_theme = Fabricate(:theme, component: true)
upload = Fabricate(:upload) upload = Fabricate(:upload)
@ -200,8 +200,7 @@ describe Admin::ThemesController do
end end
it 'returns the right error message' do it 'returns the right error message' do
parent = Fabricate(:theme) theme.update!(component: true)
parent.add_child_theme!(theme)
put "/admin/themes/#{theme.id}.json", params: { put "/admin/themes/#{theme.id}.json", params: {
theme: { default: true } theme: { default: true }

View File

@ -122,7 +122,7 @@ RSpec.describe ApplicationController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(controller.theme_ids).to eq([theme2.id]) expect(controller.theme_ids).to eq([theme2.id])
theme2.update!(user_selectable: false) theme2.update!(user_selectable: false, component: true)
theme.add_child_theme!(theme2) theme.add_child_theme!(theme2)
cookies['theme_ids'] = "#{theme.id},#{theme2.id}|#{user.user_option.theme_key_seq}" cookies['theme_ids'] = "#{theme.id},#{theme2.id}|#{user.user_option.theme_key_seq}"

View File

@ -143,7 +143,7 @@ describe UserUpdater do
expect(user.user_option.theme_ids).to eq([]) expect(user.user_option.theme_ids).to eq([])
theme = Fabricate(:theme) theme = Fabricate(:theme)
child = Fabricate(:theme) child = Fabricate(:theme, component: true)
theme.add_child_theme!(child) theme.add_child_theme!(child)
theme.set_default! theme.set_default!

View File

@ -8,7 +8,7 @@ moduleFor("controller:admin-customize-themes", {
needs: ["controller:adminUser"] needs: ["controller:adminUser"]
}); });
QUnit.test("can list sorted themes", function(assert) { QUnit.test("can list themes correctly", function(assert) {
const defaultTheme = Theme.create({ id: 2, default: true, name: "default" }); const defaultTheme = Theme.create({ id: 2, default: true, name: "default" });
const userTheme = Theme.create({ const userTheme = Theme.create({
id: 3, id: 3,
@ -17,16 +17,25 @@ QUnit.test("can list sorted themes", function(assert) {
}); });
const strayTheme1 = Theme.create({ id: 4, name: "stray1" }); const strayTheme1 = Theme.create({ id: 4, name: "stray1" });
const strayTheme2 = Theme.create({ id: 5, name: "stray2" }); const strayTheme2 = Theme.create({ id: 5, name: "stray2" });
const componentTheme = Theme.create({
id: 6,
name: "component",
component: true
});
const controller = this.subject({ const controller = this.subject({
model: { model: [strayTheme2, strayTheme1, userTheme, defaultTheme, componentTheme]
content: [strayTheme2, strayTheme1, userTheme, defaultTheme]
}
}); });
assert.deepEqual( assert.deepEqual(
controller.get("sortedThemes").map(t => t.get("name")), controller.get("fullThemes").map(t => t.get("name")),
[defaultTheme, userTheme, strayTheme1, strayTheme2].map(t => t.get("name")), [defaultTheme, userTheme, strayTheme1, strayTheme2].map(t => t.get("name")),
"sorts themes correctly" "sorts themes correctly"
); );
assert.deepEqual(
controller.get("childThemes").map(t => t.get("name")),
[componentTheme].map(t => t.get("name")),
"separate components from themes"
);
}); });