diff --git a/app/assets/javascripts/admin/components/site-setting.js.es6 b/app/assets/javascripts/admin/components/site-setting.js.es6 index 73e8160a28ca0..98bdf950f6504 100644 --- a/app/assets/javascripts/admin/components/site-setting.js.es6 +++ b/app/assets/javascripts/admin/components/site-setting.js.es6 @@ -1,96 +1,10 @@ import BufferedContent from 'discourse/mixins/buffered-content'; import SiteSetting from 'admin/models/site-setting'; -import { propertyNotEqual } from 'discourse/lib/computed'; -import computed from 'ember-addons/ember-computed-decorators'; -import { categoryLinkHTML } from 'discourse/helpers/category-link'; - -const CustomTypes = ['bool', 'enum', 'list', 'url_list', 'host_list', 'category_list', 'value_list']; - -export default Ember.Component.extend(BufferedContent, { - classNameBindings: [':row', ':setting', 'setting.overridden', 'typeClass'], - content: Ember.computed.alias('setting'), - dirty: propertyNotEqual('buffered.value', 'setting.value'), - validationMessage: null, - - @computed("setting", "buffered.value") - preview(setting, value) { - // A bit hacky, but allows us to use helpers - if (setting.get('setting') === 'category_style') { - let category = this.site.get('categories.firstObject'); - if (category) { - return categoryLinkHTML(category, { - categoryStyle: value - }); - } - } - - let preview = setting.get('preview'); - if (preview) { - return new Handlebars.SafeString("
" + preview.replace(/\{\{value\}\}/g, value) + "
"); - } - }, - - @computed('componentType') - typeClass(componentType) { - return componentType.replace(/\_/g, '-'); - }, - - @computed("setting.setting") - settingName(setting) { - return setting.replace(/\_/g, ' '); - }, - - @computed("setting.type") - componentType(type) { - return CustomTypes.indexOf(type) !== -1 ? type : 'string'; - }, - - @computed("typeClass") - componentName(typeClass) { - return "site-settings/" + typeClass; - }, - - _watchEnterKey: function() { - const self = this; - this.$().on("keydown.site-setting-enter", ".input-setting-string", function (e) { - if (e.keyCode === 13) { // enter key - self._save(); - } - }); - }.on('didInsertElement'), - - _removeBindings: function() { - this.$().off("keydown.site-setting-enter"); - }.on("willDestroyElement"), +import SettingComponent from 'admin/mixins/setting-component'; +export default Ember.Component.extend(BufferedContent, SettingComponent, { _save() { - const setting = this.get('buffered'), - action = SiteSetting.update(setting.get('setting'), setting.get('value')); - action.then(() => { - this.set('validationMessage', null); - this.commitBuffer(); - }).catch((e) => { - if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) { - this.set('validationMessage', e.jqXHR.responseJSON.errors[0]); - } else { - this.set('validationMessage', I18n.t('generic_error')); - } - }); - }, - - actions: { - save() { - this._save(); - }, - - resetDefault() { - this.set('buffered.value', this.get('setting.default')); - this._save(); - }, - - cancel() { - this.rollbackBuffer(); - } + const setting = this.get('buffered'); + return SiteSetting.update(setting.get('setting'), setting.get('value')); } - }); diff --git a/app/assets/javascripts/admin/components/site-settings/bool.js.es6 b/app/assets/javascripts/admin/components/site-settings/bool.js.es6 index 40fcfb354b68c..6be1a14e27acb 100644 --- a/app/assets/javascripts/admin/components/site-settings/bool.js.es6 +++ b/app/assets/javascripts/admin/components/site-settings/bool.js.es6 @@ -6,7 +6,7 @@ export default Ember.Component.extend({ enabled: { get(value) { if (Ember.isEmpty(value)) { return false; } - return value === "true"; + return value.toString() === "true"; }, set(value) { this.set("value", value ? "true" : "false"); diff --git a/app/assets/javascripts/admin/components/theme-setting.js.es6 b/app/assets/javascripts/admin/components/theme-setting.js.es6 new file mode 100644 index 0000000000000..eb576e9b644bb --- /dev/null +++ b/app/assets/javascripts/admin/components/theme-setting.js.es6 @@ -0,0 +1,9 @@ +import BufferedContent from 'discourse/mixins/buffered-content'; +import SettingComponent from 'admin/mixins/setting-component'; + +export default Ember.Component.extend(BufferedContent, SettingComponent, { + layoutName: 'admin/templates/components/site-setting', + _save() { + return this.get('model').saveSettings(this.get('setting.setting'), this.get('buffered.value')); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 index fb91322edff81..e5c21b4c6fb17 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 @@ -6,11 +6,22 @@ export default Ember.Controller.extend({ section: null, targets: [ - {id: 0, name: I18n.t('admin.customize.theme.common')}, - {id: 1, name: I18n.t('admin.customize.theme.desktop')}, - {id: 2, name: I18n.t('admin.customize.theme.mobile')} + { id: 0, name: 'common' }, + { id: 1, name: 'desktop' }, + { id: 2, name: 'mobile' }, + { id: 3, name: 'settings' } ], + fieldsForTarget: function (target) { + const common = ["scss", "head_tag", "header", "after_header", "body_tag", "footer"]; + switch(target) { + case "common": return [...common, "embedded_scss"]; + case "desktop": return common; + case "mobile": return common; + case "settings": return ["yaml"]; + } + }, + @computed('onlyOverridden') showCommon() { return this.shouldShow('common'); @@ -26,6 +37,11 @@ export default Ember.Controller.extend({ return this.shouldShow('mobile'); }, + @computed('onlyOverridden') + showSettings() { + return this.shouldShow('settings'); + }, + @observes('onlyOverridden') onlyOverriddenChanged() { if (this.get('onlyOverridden')) { @@ -51,27 +67,19 @@ export default Ember.Controller.extend({ currentTarget: 0, setTargetName: function(name) { - let target; - switch(name) { - case "common": target = 0; break; - case "desktop": target = 1; break; - case "mobile": target = 2; break; - } - - this.set("currentTarget", target); + const target = this.get('targets').find(t => t.name === name); + this.set("currentTarget", target && target.id); }, @computed("currentTarget") - currentTargetName(target) { - switch(parseInt(target)) { - case 0: return "common"; - case 1: return "desktop"; - case 2: return "mobile"; - } + currentTargetName(id) { + const target = this.get('targets').find(t => t.id === parseInt(id, 10)); + return target && target.name; }, @computed("fieldName") activeSectionMode(fieldName) { + if (fieldName === "yaml") return "yaml"; return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html"; }, @@ -96,15 +104,9 @@ export default Ember.Controller.extend({ } }, - @computed("currentTarget", "onlyOverridden") + @computed("currentTargetName", "onlyOverridden") fields(target, onlyOverridden) { - let fields = [ - "scss", "head_tag", "header", "after_header", "body_tag", "footer" - ]; - - if (parseInt(target) === 0) { - fields.push("embedded_scss"); - } + let fields = this.fieldsForTarget(target); if (onlyOverridden) { const model = this.get("model"); @@ -155,5 +157,4 @@ export default Ember.Controller.extend({ }); } } - }); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 index 2873f62a9f438..b1cf6b1eddbe9 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 @@ -2,6 +2,7 @@ import { default as computed } 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'; +import ThemeSettings from 'admin/models/theme-settings'; const THEME_UPLOAD_VAR = 2; @@ -30,7 +31,7 @@ export default Ember.Controller.extend({ return text + ": " + localized.join(" , "); } }; - ['common','desktop','mobile'].forEach(target=> { + ['common', 'desktop', 'mobile', 'settings'].forEach(target => { descriptions.push(description(target)); }); return descriptions.reject(d=>Em.isBlank(d)); @@ -77,6 +78,16 @@ export default Ember.Controller.extend({ return themes; }, + @computed("model.settings") + settings(settings) { + return settings.map(setting => ThemeSettings.create(setting)); + }, + + @computed("settings") + hasSettings(settings) { + return settings.length > 0; + }, + downloadUrl: url('model.id', '/admin/themes/%@'), actions: { diff --git a/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 index d59d419ef532f..d971a555bcab5 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-import-theme.js.es6 @@ -1,12 +1,13 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; import { ajax } from 'discourse/lib/ajax'; -// import computed from 'ember-addons/ember-computed-decorators'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; export default Ember.Controller.extend(ModalFunctionality, { local: Ember.computed.equal('selection', 'local'), remote: Ember.computed.equal('selection', 'remote'), selection: 'local', adminCustomizeThemes: Ember.inject.controller(), + loading: false, actions: { importTheme() { @@ -24,11 +25,12 @@ export default Ember.Controller.extend(ModalFunctionality, { options.data = {remote: this.get('uploadUrl')}; } + this.set('loading', true); ajax('/admin/themes/import', options).then(result=>{ const theme = this.store.createRecord('theme',result.theme); this.get('adminCustomizeThemes').send('addTheme', theme); this.send('closeModal'); - }); + }).catch(popupAjaxError).finally(() => this.set('loading', false)); } } diff --git a/app/assets/javascripts/admin/mixins/setting-component.js.es6 b/app/assets/javascripts/admin/mixins/setting-component.js.es6 new file mode 100644 index 0000000000000..a5b85af9a337f --- /dev/null +++ b/app/assets/javascripts/admin/mixins/setting-component.js.es6 @@ -0,0 +1,98 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import { categoryLinkHTML } from 'discourse/helpers/category-link'; + +const CustomTypes = ['bool', 'enum', 'list', 'url_list', 'host_list', 'category_list', 'value_list']; + +export default Ember.Mixin.create({ + classNameBindings: [':row', ':setting', 'setting.overridden', 'typeClass'], + content: Ember.computed.alias('setting'), + validationMessage: null, + + @computed("buffered.value", "setting.value") + dirty(bufferVal, settingVal) { + if (bufferVal === null || bufferVal === undefined) bufferVal = ''; + if (settingVal === null || settingVal === undefined) settingVal = ''; + + return bufferVal.toString() !== settingVal.toString(); + }, + + @computed("setting", "buffered.value") + preview(setting, value) { + // A bit hacky, but allows us to use helpers + if (setting.get('setting') === 'category_style') { + let category = this.site.get('categories.firstObject'); + if (category) { + return categoryLinkHTML(category, { + categoryStyle: value + }); + } + } + + let preview = setting.get('preview'); + if (preview) { + return new Handlebars.SafeString("
" + preview.replace(/\{\{value\}\}/g, value) + "
"); + } + }, + + @computed('componentType') + typeClass(componentType) { + return componentType.replace(/\_/g, '-'); + }, + + @computed("setting.setting") + settingName(setting) { + return setting.replace(/\_/g, ' '); + }, + + @computed("setting.type") + componentType(type) { + return CustomTypes.indexOf(type) !== -1 ? type : 'string'; + }, + + @computed("typeClass") + componentName(typeClass) { + return "site-settings/" + typeClass; + }, + + _watchEnterKey: function() { + const self = this; + this.$().on("keydown.setting-enter", ".input-setting-string", function (e) { + if (e.keyCode === 13) { // enter key + self._save(); + } + }); + }.on('didInsertElement'), + + _removeBindings: function() { + this.$().off("keydown.setting-enter"); + }.on("willDestroyElement"), + + _save() { + Em.warn("You should define a `_save` method", { id: "admin.mixins.setting-component" }); + return Ember.RSVP.resolve(); + }, + + actions: { + save() { + this._save().then(() => { + this.set('validationMessage', null); + this.commitBuffer(); + }).catch(e => { + if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) { + this.set('validationMessage', e.jqXHR.responseJSON.errors[0]); + } else { + this.set('validationMessage', I18n.t('generic_error')); + } + }); + }, + + resetDefault() { + this.set('buffered.value', this.get('setting.default')); + this._save(); + }, + + cancel() { + this.rollbackBuffer(); + } + } +}); diff --git a/app/assets/javascripts/admin/mixins/setting-object.js.es6 b/app/assets/javascripts/admin/mixins/setting-object.js.es6 new file mode 100644 index 0000000000000..0b16ed50095e9 --- /dev/null +++ b/app/assets/javascripts/admin/mixins/setting-object.js.es6 @@ -0,0 +1,29 @@ +export default Ember.Mixin.create({ + overridden: function() { + let val = this.get('value'), + defaultVal = this.get('default'); + + if (val === null) val = ''; + if (defaultVal === null) defaultVal = ''; + + return val.toString() !== defaultVal.toString(); + }.property('value', 'default'), + + validValues: function() { + const vals = [], + translateNames = this.get('translate_names'); + + this.get('valid_values').forEach(v => { + if (v.name && v.name.length > 0 && translateNames) { + vals.addObject({ name: I18n.t(v.name), value: v.value }); + } else { + vals.addObject(v); + } + }); + return vals; + }.property('valid_values'), + + allowsNone: function() { + if ( _.indexOf(this.get('valid_values'), '') >= 0 ) return 'admin.settings.none'; + }.property('valid_values') +}); diff --git a/app/assets/javascripts/admin/models/site-setting.js.es6 b/app/assets/javascripts/admin/models/site-setting.js.es6 index 6ed78787968fd..3fc3cf43f5ca2 100644 --- a/app/assets/javascripts/admin/models/site-setting.js.es6 +++ b/app/assets/javascripts/admin/models/site-setting.js.es6 @@ -1,31 +1,7 @@ import { ajax } from 'discourse/lib/ajax'; -const SiteSetting = Discourse.Model.extend({ - overridden: function() { - let val = this.get('value'), - defaultVal = this.get('default'); +import Setting from 'admin/mixins/setting-object'; - if (val === null) val = ''; - if (defaultVal === null) defaultVal = ''; - - return val.toString() !== defaultVal.toString(); - }.property('value', 'default'), - - validValues: function() { - const vals = [], - translateNames = this.get('translate_names'); - - this.get('valid_values').forEach(function(v) { - if (v.name && v.name.length > 0) { - vals.addObject(translateNames ? {name: I18n.t(v.name), value: v.value} : v); - } - }); - return vals; - }.property('valid_values'), - - allowsNone: function() { - if ( _.indexOf(this.get('valid_values'), '') >= 0 ) return 'admin.site_settings.none'; - }.property('valid_values') -}); +const SiteSetting = Discourse.Model.extend(Setting, {}); SiteSetting.reopenClass({ findAll() { diff --git a/app/assets/javascripts/admin/models/theme-settings.js.es6 b/app/assets/javascripts/admin/models/theme-settings.js.es6 new file mode 100644 index 0000000000000..b1e824cf493cf --- /dev/null +++ b/app/assets/javascripts/admin/models/theme-settings.js.es6 @@ -0,0 +1,3 @@ +import Setting from 'admin/mixins/setting-object'; + +export default Discourse.Model.extend(Setting, {}); diff --git a/app/assets/javascripts/admin/models/theme.js.es6 b/app/assets/javascripts/admin/models/theme.js.es6 index 742d06317fdb2..44e7f2ea24289 100644 --- a/app/assets/javascripts/admin/models/theme.js.es6 +++ b/app/assets/javascripts/admin/models/theme.js.es6 @@ -2,6 +2,7 @@ import RestModel from 'discourse/models/rest'; import { default as computed } from 'ember-addons/ember-computed-decorators'; const THEME_UPLOAD_VAR = 2; +const FIELDS_IDS = [0, 1, 5]; const Theme = RestModel.extend({ @@ -14,13 +15,11 @@ const Theme = RestModel.extend({ } let hash = {}; - if (fields) { - fields.forEach(field=>{ - if (!field.type_id || field.type_id < THEME_UPLOAD_VAR) { - hash[this.getKey(field)] = field; - } - }); - } + fields.forEach(field => { + if (!field.type_id || FIELDS_IDS.includes(field.type_id)) { + hash[this.getKey(field)] = field; + } + }); return hash; }, @@ -29,11 +28,11 @@ const Theme = RestModel.extend({ if (!fields) { return []; } - return fields.filter((f)=> f.target === 'common' && f.type_id === THEME_UPLOAD_VAR); + return fields.filter(f => f.target === 'common' && f.type_id === THEME_UPLOAD_VAR); }, getKey(field){ - return field.target + " " + field.name; + return `${field.target} ${field.name}`; }, hasEdited(target, name){ @@ -151,6 +150,11 @@ const Theme = RestModel.extend({ .then(() => this.set("changed", false)); }, + saveSettings(name, value) { + const settings = {}; + settings[name] = value; + return this.save({ settings }); + } }); export default Theme; diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 index c1d3b225ffde3..d9e7e6249fd99 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 @@ -18,6 +18,11 @@ export default Ember.Route.extend({ }, setupController(controller, wrapper) { + const fields = controller.fieldsForTarget(wrapper.target); + if (!fields.includes(wrapper.field_name)) { + this.transitionTo('adminCustomizeThemes.edit', wrapper.model.id, wrapper.target, fields[0]); + return; + } controller.set("model", wrapper.model); controller.setTargetName(wrapper.target || "common"); controller.set("fieldName", wrapper.field_name || "scss"); diff --git a/app/assets/javascripts/admin/templates/components/site-setting.hbs b/app/assets/javascripts/admin/templates/components/site-setting.hbs index 7fd80e4446272..e91922f7a77be 100644 --- a/app/assets/javascripts/admin/templates/components/site-setting.hbs +++ b/app/assets/javascripts/admin/templates/components/site-setting.hbs @@ -10,5 +10,5 @@ {{d-button class="cancel" action="cancel" icon="times"}} {{else if setting.overridden}} - {{d-button action="resetDefault" icon="undo" label="admin.site_settings.reset"}} + {{d-button action="resetDefault" icon="undo" label="admin.settings.reset"}} {{/if}} diff --git a/app/assets/javascripts/admin/templates/customize-colors-show.hbs b/app/assets/javascripts/admin/templates/customize-colors-show.hbs index e688e1331668c..08cbef2ddc634 100644 --- a/app/assets/javascripts/admin/templates/customize-colors-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-colors-show.hbs @@ -22,7 +22,7 @@ diff --git a/app/assets/javascripts/admin/templates/customize-themes-edit.hbs b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs index 83eebb83d0891..c798836b767ef 100644 --- a/app/assets/javascripts/admin/templates/customize-themes-edit.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs @@ -3,39 +3,47 @@

{{i18n 'admin.customize.theme.edit_css_html'}} {{#link-to 'adminCustomizeThemes.show' model.id replace=true}}{{model.name}}{{/link-to}}

{{#if error}} -
{{error}}
+
{{error}}
{{/if}}
diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs index 7f339d63d7480..b51061359badb 100644 --- a/app/assets/javascripts/admin/templates/customize-themes-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs @@ -50,16 +50,16 @@

{{i18n "admin.customize.theme.css_html"}}

{{#if hasEditedFields}} -

{{i18n "admin.customize.theme.custom_sections"}}

- +

{{i18n "admin.customize.theme.custom_sections"}}

+ {{else}} -

- {{i18n "admin.customize.theme.edit_css_html_help"}} -

+

+ {{i18n "admin.customize.theme.edit_css_html_help"}} +

{{/if}}

{{#if model.remote_theme}} @@ -71,17 +71,17 @@ {{/if}} {{#d-button action="editTheme" class="btn edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}} {{#if model.remote_theme}} - - {{#if updatingRemote}} - {{i18n 'admin.customize.theme.updating'}} - {{else}} - {{#if model.remote_theme.commits_behind}} - {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}} + + {{#if updatingRemote}} + {{i18n 'admin.customize.theme.updating'}} {{else}} - {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}} + {{#if model.remote_theme.commits_behind}} + {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}} + {{else}} + {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}} + {{/if}} {{/if}} - {{/if}} - + {{/if}}

@@ -105,6 +105,17 @@ {{#d-button action="addUploadModal" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}}

+

{{i18n "admin.customize.theme.theme_settings"}}

+ {{#d-section class="form-horizontal theme settings"}} + {{#if hasSettings}} + {{#each settings as |setting|}} + {{theme-setting setting=setting model=model class="theme-setting"}} + {{/each}} + {{else}} + {{i18n "admin.customize.theme.no_settings"}} + {{/if}} + {{/d-section}} + {{#if availableChildThemes}}

{{i18n "admin.customize.theme.theme_components"}}

{{#unless model.childThemes.length}} diff --git a/app/assets/javascripts/admin/templates/site-settings-category.hbs b/app/assets/javascripts/admin/templates/site-settings-category.hbs index 2e44f80c38cea..1af19ed7b615b 100644 --- a/app/assets/javascripts/admin/templates/site-settings-category.hbs +++ b/app/assets/javascripts/admin/templates/site-settings-category.hbs @@ -1,7 +1,7 @@ {{#if filteredContent}} {{#d-section class="form-horizontal settings"}} {{#each filteredContent as |setting|}} - {{site-setting setting=setting saveAction="saveSetting"}} + {{site-setting setting=setting}} {{/each}} {{/d-section}} {{else}} diff --git a/app/assets/javascripts/admin/templates/site-settings.hbs b/app/assets/javascripts/admin/templates/site-settings.hbs index 4561129eb868d..e3d9f4d8756ba 100644 --- a/app/assets/javascripts/admin/templates/site-settings.hbs +++ b/app/assets/javascripts/admin/templates/site-settings.hbs @@ -2,7 +2,7 @@
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index 841e4d4d991c0..8ba862a92aac2 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -55,6 +55,21 @@ } } + .theme.settings { + .theme-setting { + padding-bottom: 0; + padding-top: 18px; + min-height: 35px; + } + .setting-label { + width: 25%; + h3 { + margin-top: 0; + margin-bottom: .5rem; + } + } + } + .current-style.maximized { position: fixed; top: 0; diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index b0f8d21f39638..3013c1c2844e8 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -138,6 +138,7 @@ def update end set_fields + update_settings save_remote = false if params[:theme][:remote_check] @@ -158,7 +159,7 @@ def update update_default_theme log_theme_change(original_json, @theme) - format.json { render json: @theme, status: :created } + format.json { render json: @theme, status: :ok } else format.json { @@ -193,7 +194,7 @@ def show response.headers['Content-Disposition'] = "attachment; filename=#{@theme.name.parameterize}.dcstyle.json" response.sending_file = true - render json: ThemeWithEmbeddedUploadsSerializer.new(@theme, root: 'theme') + render json: ::ThemeWithEmbeddedUploadsSerializer.new(@theme, root: 'theme') end end @@ -223,6 +224,7 @@ def theme_params :color_scheme_id, :default, :user_selectable, + settings: {}, theme_fields: [:name, :target, :value, :upload_id, :type_id], child_theme_ids: [] ) @@ -243,6 +245,14 @@ def set_fields end end + def update_settings + return unless target_settings = theme_params[:settings] + + target_settings.each_pair do |setting_name, new_value| + @theme.update_setting(setting_name.to_sym, new_value) + end + end + def log_theme_change(old_record, new_record) StaffActionLogger.new(current_user).log_theme_change(old_record, new_record) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 653ba952fc1a1..08739a425b6af 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -460,6 +460,7 @@ def locale_from_header def preload_anonymous_data store_preloaded("site", Site.json_for(guardian)) store_preloaded("siteSettings", SiteSetting.client_settings_json) + store_preloaded("themeSettings", Theme.settings_for_client(@theme_key)) store_preloaded("customHTML", custom_html_json) store_preloaded("banner", banner_json) store_preloaded("customEmoji", custom_emoji) diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb index 1b8bfba1ed3f7..9d11932744832 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -76,6 +76,8 @@ def update_from_remote(importer = nil) end Theme.targets.keys.each do |target| + next if target == :settings + ALLOWED_FIELDS.each do |field| lookup = if field == "scss" @@ -91,6 +93,9 @@ def update_from_remote(importer = nil) end end + settings_yaml = importer["settings.yaml"] || importer["settings.yml"] + theme.set_field(target: :settings, name: "yaml", value: settings_yaml) + self.license_url ||= theme_info["license_url"] self.about_url ||= theme_info["about_url"] self.remote_updated_at = Time.zone.now diff --git a/app/models/theme.rb b/app/models/theme.rb index e8aeaf0553176..8e6c152163ff1 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -1,6 +1,8 @@ require_dependency 'distributed_cache' require_dependency 'stylesheet/compiler' require_dependency 'stylesheet/manager' +require_dependency 'theme_settings_parser' +require_dependency 'theme_settings_manager' class Theme < ActiveRecord::Base @@ -8,6 +10,7 @@ class Theme < ActiveRecord::Base belongs_to :color_scheme has_many :theme_fields, dependent: :destroy + has_many :theme_settings, dependent: :destroy has_many :child_theme_relation, class_name: 'ChildTheme', foreign_key: 'parent_theme_id', dependent: :destroy has_many :child_themes, through: :child_theme_relation, source: :child_theme has_many :color_schemes @@ -34,11 +37,13 @@ def notify_color_change(color) @included_themes = nil remove_from_cache! + clear_cached_settings! notify_scheme_change if saved_change_to_color_scheme_id? end after_destroy do remove_from_cache! + clear_cached_settings! if SiteSetting.default_theme_key == self.key Theme.clear_default! end @@ -122,7 +127,11 @@ def self.clear_cache! end def self.targets - @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2) + @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3) + end + + def self.lookup_target(target_id) + self.targets.invert[target_id] end def notify_scheme_change(clear_manager_cache = true) @@ -288,6 +297,56 @@ def add_child_theme!(theme) child_themes.reload save! end + + def settings + field = theme_fields.where(target_id: Theme.targets[:settings], name: "yaml").first + return [] unless field && field.error.nil? + + settings = [] + ThemeSettingsParser.new(field).load do |name, default, type, opts| + settings << ThemeSettingsManager.create(name, default, type, self, opts) + end + settings + end + + def cached_settings + Rails.cache.fetch("settings_for_theme_#{self.key}", expires_in: 30.minutes) do + hash = {} + self.settings.each do |setting| + hash[setting.name] = setting.value + end + hash + end + end + + def clear_cached_settings! + Rails.cache.delete("settings_for_theme_#{self.key}") + end + + def included_settings + hash = {} + + self.included_themes.each do |theme| + hash.merge!(theme.cached_settings) + end + + hash.merge!(self.cached_settings) + hash + end + + def self.settings_for_client(key) + theme = Theme.find_by(key: key) + return {}.to_json unless theme + + theme.included_settings.to_json + end + + def update_setting(setting_name, new_value) + target_setting = settings.find { |setting| setting.name == setting_name } + raise Discourse::NotFound unless target_setting + + target_setting.value = new_value + end end # == Schema Information diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index eb1dcd2bc26a6..ac8ffdd53af5e 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -1,3 +1,5 @@ +require_dependency 'theme_settings_parser' + class ThemeField < ActiveRecord::Base belongs_to :upload @@ -7,7 +9,8 @@ def self.types scss: 1, theme_upload_var: 2, theme_color_var: 3, - theme_var: 4) + theme_var: 4, + yaml: 5) end def self.theme_var_type_ids @@ -77,11 +80,54 @@ def process_html(html) [doc.to_s, errors&.join("\n")] end + def validate_yaml! + return unless self.name == "yaml" + + errors = [] + begin + ThemeSettingsParser.new(self).load do |name, default, type, opts| + setting = ThemeSetting.new(name: name, data_type: type, theme: theme) + translation_key = "themes.settings_errors" + + if setting.invalid? + setting.errors.details.each_pair do |attribute, _errors| + _errors.each do |hash| + errors << I18n.t("#{translation_key}.#{attribute}_#{hash[:error]}", name: name) + end + end + end + + if default.nil? + errors << I18n.t("#{translation_key}.default_value_missing", name: name) + end + + if (min = opts[:min]) && (max = opts[:max]) + unless ThemeSetting.value_in_range?(default, (min..max), type) + errors << I18n.t("#{translation_key}.default_out_range", name: name) + end + end + + unless ThemeSetting.acceptable_value_for_type?(default, type) + errors << I18n.t("#{translation_key}.default_not_match_type", name: name) + end + end + rescue ThemeSettingsParser::InvalidYaml => e + errors << e.message + end + + self.error = errors.join("\n").presence unless self.destroyed? + if will_save_change_to_error? + update_columns(error: self.error) + end + end + def self.guess_type(name) if html_fields.include?(name.to_s) types[:html] elsif scss_fields.include?(name.to_s) types[:scss] + elsif name.to_s === "yaml" + types[:yaml] end end @@ -121,7 +167,7 @@ def ensure_scss_compiles! ) self.error = nil unless error.nil? rescue SassC::SyntaxError => e - self.error = e.message + self.error = e.message unless self.destroyed? end if will_save_change_to_error? @@ -143,6 +189,8 @@ def target_name after_commit do ensure_baked! ensure_scss_compiles! + validate_yaml! + theme.clear_cached_settings! Stylesheet::Manager.clear_theme_cache! if self.name.include?("scss") diff --git a/app/models/theme_setting.rb b/app/models/theme_setting.rb new file mode 100644 index 0000000000000..136c78ecbb695 --- /dev/null +++ b/app/models/theme_setting.rb @@ -0,0 +1,68 @@ +class ThemeSetting < ActiveRecord::Base + belongs_to :theme + + validates_presence_of :name, :theme + validates :data_type, numericality: { only_integer: true } + validates :name, length: { maximum: 255 } + + after_save do + theme.clear_cached_settings! + end + + def self.types + @types ||= Enum.new(integer: 0, float: 1, string: 2, bool: 3, list: 4, enum: 5) + end + + def self.acceptable_value_for_type?(value, type) + case type + when self.types[:integer] + value.is_a?(Integer) + when self.types[:float] + value.is_a?(Integer) || value.is_a?(Float) + when self.types[:bool] + value.is_a?(TrueClass) || value.is_a?(FalseClass) + when self.types[:list] + value.is_a?(String) + else + true + end + end + + def self.value_in_range?(value, range, type) + if type == self.types[:integer] || type == self.types[:float] + range.include? value + elsif type == self.types[:string] + range.include? value.to_s.length + end + end + + def self.guess_type(value) + case value + when Integer + types[:integer] + when Float + types[:float] + when String + types[:string] + when TrueClass, FalseClass + types[:bool] + end + end +end + +# == Schema Information +# +# Table name: theme_settings +# +# id :integer not null, primary key +# name :string(255) not null +# data_type :integer not null +# value :string +# theme_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_theme_settings_on_theme_id (theme_id) +# diff --git a/app/serializers/theme_serializer.rb b/app/serializers/theme_serializer.rb index 78532e399a9da..8cf183b4a8d5a 100644 --- a/app/serializers/theme_serializer.rb +++ b/app/serializers/theme_serializer.rb @@ -24,11 +24,7 @@ def filename end def target - case object.target_id - when 0 then "common" - when 1 then "desktop" - when 2 then "mobile" - end + Theme.lookup_target(object.target_id)&.to_s end def include_error? @@ -60,7 +56,7 @@ def about_url end class ThemeSerializer < ChildThemeSerializer - attributes :color_scheme, :color_scheme_id, :user_selectable, :remote_theme_id + attributes :color_scheme, :color_scheme_id, :user_selectable, :remote_theme_id, :settings has_many :theme_fields, serializer: ThemeFieldSerializer, embed: :objects has_many :child_themes, serializer: ChildThemeSerializer, embed: :objects @@ -69,6 +65,10 @@ class ThemeSerializer < ChildThemeSerializer def child_themes object.child_themes.order(:name) end + + def settings + object.settings.map { |setting| ThemeSettingsSerializer.new(setting, root: false) } + end end class ThemeFieldWithEmbeddedUploadsSerializer < ThemeFieldSerializer @@ -94,4 +94,8 @@ def raw_upload class ThemeWithEmbeddedUploadsSerializer < ThemeSerializer has_many :theme_fields, serializer: ThemeFieldWithEmbeddedUploadsSerializer, embed: :objects + + def include_settings? + false + end end diff --git a/app/serializers/theme_settings_serializer.rb b/app/serializers/theme_settings_serializer.rb new file mode 100644 index 0000000000000..cb3aa20794bfd --- /dev/null +++ b/app/serializers/theme_settings_serializer.rb @@ -0,0 +1,35 @@ +class ThemeSettingsSerializer < ApplicationSerializer + attributes :setting, :type, :default, :value, :description, :valid_values + + def setting + object.name + end + + def type + object.type_name + end + + def default + object.default + end + + def value + object.value + end + + def description + object.description + end + + def valid_values + object.choices + end + + def include_valid_values? + object.type == ThemeSetting.types[:enum] + end + + def include_description? + object.description.present? + end +end diff --git a/app/views/common/_discourse_javascript.html.erb b/app/views/common/_discourse_javascript.html.erb index 3211308db2044..dac6b19fbe96e 100644 --- a/app/views/common/_discourse_javascript.html.erb +++ b/app/views/common/_discourse_javascript.html.erb @@ -42,6 +42,7 @@ Discourse.BaseUri = '<%= Discourse::base_uri %>'; Discourse.Environment = '<%= Rails.env %>'; Discourse.SiteSettings = ps.get('siteSettings'); + Discourse.ThemeSettings = ps.get('themeSettings'); Discourse.LetterAvatarVersion = '<%= LetterAvatar.version %>'; Discourse.MarkdownItURL = '<%= asset_url('markdown-it-bundle.js') %>'; Discourse.ServiceWorkerURL = '<%= Rails.application.assets_manifest.assets['service-worker.js'] %>' diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 65ebd21d03c97..c2c3475806049 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3045,6 +3045,7 @@ en: common: "Common" desktop: "Desktop" mobile: "Mobile" + settings: "Settings" preview: "Preview" is_default: "Theme is enabled by default" user_selectable: "Theme can be selected by users" @@ -3073,6 +3074,8 @@ en: updating: "Updating..." up_to_date: "Theme is up-to-date, last checked:" add: "Add" + theme_settings: "Theme Settings" + no_settings: "This theme has no settings." commits_behind: one: "Theme is 1 commit behind!" other: "Theme is {{count}} commits behind!" @@ -3097,6 +3100,9 @@ en: body_tag: text: "" title: "HTML that will be inserted before the tag" + yaml: + text: "YAML" + title: "Define theme settings in YAML format" colors: select_base: title: "Select base color scheme" @@ -3633,11 +3639,12 @@ en: recommended: "We recommend customizing the following text to suit your needs:" show_overriden: 'Only show overridden' - site_settings: + settings: # used by theme and site settings show_overriden: 'Only show overridden' - title: 'Settings' reset: 'reset' none: 'none' + site_settings: + title: 'Settings' no_results: "No results found." clear_filter: "Clear" add_url: "add URL" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2da213f1b575f..006e1a2408523 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -59,6 +59,22 @@ en: themes: bad_color_scheme: "Can not update theme, invalid color scheme" other_error: "Something went wrong updating theme" + 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`" + name_too_long: "There is a setting with a too long name. Maximum length is 255" + default_value_missing: "Setting `%{name}` has no default value" + default_not_match_type: "Setting `%{name}` default value's type doesn't match with the setting type." + default_out_range: "Setting `%{name}` default value isn't in the specified range." + enum_value_not_valid: "Selected value isn't one of the enum choices." + number_value_not_valid: "New value isn't within the allowed range." + number_value_not_valid_min_max: "It must be between %{min} and %{max}." + number_value_not_valid_min: "It must be larger than or equal to %{min}." + number_value_not_valid_max: "It must be smaller than or equal to %{max}." + string_value_not_valid: "New value length isn't within the allowed range." + string_value_not_valid_min_max: "It must be between %{min} and %{max} character long." + string_value_not_valid_min: "It must be at least %{min} characters long." + string_value_not_valid_max: "It must be at most %{max} characters long." emails: incoming: default_subject: "This topic needs a title" diff --git a/db/migrate/20180118215249_create_theme_settings.rb b/db/migrate/20180118215249_create_theme_settings.rb new file mode 100644 index 0000000000000..4803641ec286b --- /dev/null +++ b/db/migrate/20180118215249_create_theme_settings.rb @@ -0,0 +1,12 @@ +class CreateThemeSettings < ActiveRecord::Migration[5.1] + def change + create_table :theme_settings do |t| + t.string :name, limit: 255, null: false + t.integer :data_type, null: false + t.text :value + t.integer :theme_id, null: false + + t.timestamps null: false + end + end +end diff --git a/lib/stylesheet/importer.rb b/lib/stylesheet/importer.rb index f55d1d2ec7452..a18bb4ae1f6e7 100644 --- a/lib/stylesheet/importer.rb +++ b/lib/stylesheet/importer.rb @@ -39,6 +39,7 @@ def self.register_import(name, &blk) colors.each do |n, hex| contents << "$#{n}: ##{hex} !default;\n" end + theme&.all_theme_variables&.each do |field| if field.type_id == ThemeField.types[:theme_upload_var] if upload = field.upload @@ -46,11 +47,14 @@ def self.register_import(name, &blk) contents << "$#{field.name}: unquote(\"#{url}\");\n" end else - escaped = field.value.gsub('"', "\\22") - escaped.gsub!("\n", "\\A") - contents << "$#{field.name}: unquote(\"#{escaped}\");\n" + contents << to_scss_variable(field.name, field.value) end end + + theme&.included_settings&.each do |name, value| + contents << to_scss_variable(name, value) + end + Import.new("theme_variable.scss", source: contents) end @@ -132,6 +136,12 @@ def category_css(category) "body.category-#{category.full_slug} { background-image: url(#{upload_cdn_path(category.uploaded_background.url)}) }\n" end + def to_scss_variable(name, value) + escaped = value.to_s.gsub('"', "\\22") + escaped.gsub!("\n", "\\A") + "$#{name}: unquote(\"#{escaped}\");\n" + end + def imports(asset, parent_path) if asset[-1] == "*" Dir["#{Stylesheet::ASSET_ROOT}/#{asset}.scss"].map do |path| diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb index 3055eb9568916..369f65038d4f0 100644 --- a/lib/stylesheet/manager.rb +++ b/lib/stylesheet/manager.rb @@ -252,7 +252,11 @@ def theme_digest raise "attempting to look up theme digest for invalid field" end - Digest::SHA1.hexdigest(scss.to_s + color_scheme_digest.to_s) + Digest::SHA1.hexdigest(scss.to_s + color_scheme_digest.to_s + settings_digest) + end + + def settings_digest + Digest::SHA1.hexdigest((theme&.included_settings || {}).to_json) end def color_scheme_digest diff --git a/lib/theme_settings_manager.rb b/lib/theme_settings_manager.rb new file mode 100644 index 0000000000000..537b2c7de1141 --- /dev/null +++ b/lib/theme_settings_manager.rb @@ -0,0 +1,157 @@ +class ThemeSettingsManager + attr_reader :name, :theme, :default + + def self.types + ThemeSetting.types + end + + def self.create(name, default, type, theme, opts = {}) + type_name = self.types.invert[type].downcase.capitalize + klass = "ThemeSettingsManager::#{type_name}".constantize + klass.new(name, default, theme, opts) + end + + def initialize(name, default, theme, opts = {}) + @name = name.to_sym + @default = default + @theme = theme + @opts = opts + @types = self.class.types + end + + def value + has_record? ? db_record.value : @default + end + + def type_name + self.class.name.demodulize.downcase.to_sym + end + + def type + @types[type_name] + end + + def description + @opts[:description] + end + + def value=(new_value) + ensure_is_valid_value!(new_value) + + record = has_record? ? db_record : create_record! + record.value = new_value.to_s + record.save! + record.value + end + + def db_record + ThemeSetting.where(name: @name, data_type: type, theme: @theme).first + end + + def has_record? + db_record.present? + end + + def create_record! + record = ThemeSetting.new(name: @name, data_type: type, theme: @theme) + record.save! + record + end + + def is_valid_value?(new_value) + true + end + + def invalid_value_error_message + name = type == @types[:integer] || type == @types[:float] ? "number" : type_name + primary_key = "themes.settings_errors.#{name}_value_not_valid" + + secondary_key = primary_key + secondary_key += "_min" if has_min? + secondary_key += "_max" if has_max? + + translation = I18n.t(primary_key) + return translation if secondary_key == primary_key + + translation += " #{I18n.t(secondary_key, min: @opts[:min], max: @opts[:max])}" + translation + end + + def ensure_is_valid_value!(new_value) + unless is_valid_value?(new_value) + raise Discourse::InvalidParameters.new invalid_value_error_message + end + end + + def has_min? + min = @opts[:min] + (min.is_a?(::Integer) || min.is_a?(::Float)) && min != -::Float::INFINITY + end + + def has_max? + max = @opts[:max] + (max.is_a?(::Integer) || max.is_a?(::Float)) && max != ::Float::INFINITY + end + + class List < self; end + class String < self + def is_valid_value?(new_value) + (@opts[:min]..@opts[:max]).include? new_value.to_s.length + end + end + + class Bool < self + def value + [true, "true"].include?(super) + end + + def value=(new_value) + new_value = ([true, "true"].include?(new_value)).to_s + super(new_value) + end + end + + class Integer < self + def value + super.to_i + end + + def value=(new_value) + super(new_value.to_i) + end + + def is_valid_value?(new_value) + (@opts[:min]..@opts[:max]).include? new_value.to_i + end + end + + class Float < self + def value + super.to_f + end + + def value=(new_value) + super(new_value.to_f) + end + + def is_valid_value?(new_value) + (@opts[:min]..@opts[:max]).include? new_value.to_f + end + end + + class Enum < self + def value + val = super + match = choices.find { |choice| choice == val || choice.to_s == val } + match || val + end + + def is_valid_value?(new_value) + choices.include?(new_value) || choices.map(&:to_s).include?(new_value) + end + + def choices + @opts[:choices] + end + end +end diff --git a/lib/theme_settings_parser.rb b/lib/theme_settings_parser.rb new file mode 100644 index 0000000000000..475d24a948e48 --- /dev/null +++ b/lib/theme_settings_parser.rb @@ -0,0 +1,66 @@ +class ThemeSettingsParser + class InvalidYaml < StandardError; end + + def initialize(setting_field) + @setting_field = setting_field + @types = ThemeSetting.types + end + + def extract_description(desc) + return desc if desc.is_a?(String) + + if desc.is_a?(Hash) + default_locale = SiteSetting.default_locale.to_sym + fallback_locale = desc.keys.find { |key| I18n.locale_available?(key) } + locale = desc[I18n.locale] || desc[default_locale] || desc[:en] || desc[fallback_locale] + + locale if locale.is_a?(String) + end + end + + def create_opts(default, type, raw_opts = {}) + opts = {} + opts[:description] = extract_description(raw_opts[:description]) + + if type == @types[:enum] + choices = raw_opts[:choices] + choices = [] unless choices.is_a?(Array) + choices << default unless choices.include?(default) + opts[:choices] = choices + end + + if [@types[:integer], @types[:string], @types[:float]].include?(type) + opts[:max] = raw_opts[:max].is_a?(Numeric) ? raw_opts[:max] : Float::INFINITY + opts[:min] = raw_opts[:min].is_a?(Numeric) ? raw_opts[:min] : -Float::INFINITY + end + opts + end + + def load + return if @setting_field.value.blank? + + begin + parsed = YAML.safe_load(@setting_field.value) + rescue Psych::SyntaxError, Psych::DisallowedClass => e + raise InvalidYaml.new(e.message) + end + raise InvalidYaml.new(I18n.t("themes.settings_errors.invalid_yaml")) unless parsed.is_a?(Hash) + + parsed.deep_symbolize_keys! + + parsed.each_pair do |setting, value| + if (type = ThemeSetting.guess_type(value)).present? + result = [setting, value, type, create_opts(value, type)] + elsif (hash = value).is_a?(Hash) + default = hash[:default] + type = hash.key?(:type) ? @types[hash[:type]&.to_sym] : ThemeSetting.guess_type(default) + + result = [setting, default, type, create_opts(default, type, hash)] + else + result = [setting, value, nil, {}] + end + + yield(*result) + end + end +end diff --git a/spec/components/theme_settings_manager_spec.rb b/spec/components/theme_settings_manager_spec.rb new file mode 100644 index 0000000000000..f5b9061920555 --- /dev/null +++ b/spec/components/theme_settings_manager_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' +require 'theme_settings_manager' + +describe ThemeSettingsManager do + + let(:theme_settings) do + theme = Theme.create!(name: "awesome theme", user_id: -1) + yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/valid_settings.yaml") + theme.set_field(target: :settings, name: "yaml", value: yaml) + theme.save! + theme.settings + end + + def find_by_name(name) + theme_settings.find { |setting| setting.name == name } + end + + context "Enum" do + it "only accepts values from its choices" do + enum_setting = find_by_name(:enum_setting) + expect { enum_setting.value = "trust level 2" }.to raise_error(Discourse::InvalidParameters) + expect { enum_setting.value = "trust level 0" }.not_to raise_error + + enum_setting = find_by_name(:enum_setting_02) + expect { enum_setting.value = "10" }.not_to raise_error + + enum_setting = find_by_name(:enum_setting_03) + expect { enum_setting.value = "10" }.not_to raise_error + expect { enum_setting.value = 1 }.not_to raise_error + expect { enum_setting.value = 15 }.to raise_error(Discourse::InvalidParameters) + end + end + + context "Bool" do + it "is either true or false" do + bool_setting = find_by_name(:boolean_setting) + expect(bool_setting.value).to eq(true) # default + + bool_setting.value = "true" + expect(bool_setting.value).to eq(true) + + bool_setting.value = "falsse" # intentionally misspelled + expect(bool_setting.value).to eq(false) + + bool_setting.value = true + expect(bool_setting.value).to eq(true) + end + end + + context "Integer" do + it "is always an integer" do + int_setting = find_by_name(:integer_setting) + int_setting.value = 1.6 + expect(int_setting.value).to eq(1) + + int_setting.value = "4.3" + expect(int_setting.value).to eq(4) + + int_setting.value = "10" + expect(int_setting.value).to eq(10) + + int_setting.value = "text" + expect(int_setting.value).to eq(0) + end + + it "can have min or max value" do + int_setting = find_by_name(:integer_setting_02) + expect { int_setting.value = 0 }.to raise_error(Discourse::InvalidParameters) + expect { int_setting.value = 61 }.to raise_error(Discourse::InvalidParameters) + + int_setting.value = 60 + expect(int_setting.value).to eq(60) + + int_setting.value = 1 + expect(int_setting.value).to eq(1) + end + end + + context "Float" do + it "is always a float" do + float_setting = find_by_name(:float_setting) + float_setting.value = 1.615 + expect(float_setting.value).to eq(1.615) + + float_setting.value = "3.1415" + expect(float_setting.value).to eq(3.1415) + + float_setting.value = 10 + expect(float_setting.value).to eq(10) + end + + it "can have min or max value" do + float_setting = find_by_name(:float_setting) + expect { float_setting.value = 1.4 }.to raise_error(Discourse::InvalidParameters) + expect { float_setting.value = 10.01 }.to raise_error(Discourse::InvalidParameters) + expect { float_setting.value = "text" }.to raise_error(Discourse::InvalidParameters) + + float_setting.value = 9.521 + expect(float_setting.value).to eq(9.521) + end + end + + context "String" do + it "can have min or max length" do + string_setting = find_by_name(:string_setting_02) + expect { string_setting.value = "a" }.to raise_error(Discourse::InvalidParameters) + + string_setting.value = "ab" + expect(string_setting.value).to eq("ab") + + string_setting.value = "ab" * 10 + expect(string_setting.value).to eq("ab" * 10) + + expect { string_setting.value = ("a" * 21) }.to raise_error(Discourse::InvalidParameters) + end + end +end diff --git a/spec/components/theme_settings_parser_spec.rb b/spec/components/theme_settings_parser_spec.rb new file mode 100644 index 0000000000000..cfdea790e63f8 --- /dev/null +++ b/spec/components/theme_settings_parser_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' +require 'theme_settings_parser' + +describe ThemeSettingsParser do + after(:all) do + ThemeField.destroy_all + end + + def types + ThemeSetting.types + end + + class Loader + def initialize + @settings ||= [] + load_settings + end + + def load_settings + yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/valid_settings.yaml") + field = ThemeField.create!(theme_id: 1, target_id: 3, name: "yaml", value: yaml) + + ThemeSettingsParser.new(field).load do |name, default, type, opts| + @settings << setting(name, default, type, opts) + end + end + + def setting(name, default, type, opts = {}) + { name: name, default: default, type: type, opts: opts } + end + + def find_by_name(name) + @settings.find { |setting| setting[:name] == name } + end + end + + let(:loader) { Loader.new } + + it "guesses types correctly" do + expect(loader.find_by_name(:boolean_setting)[:type]).to eq(types[:bool]) + expect(loader.find_by_name(:boolean_setting_02)[:type]).to eq(types[:bool]) + expect(loader.find_by_name(:string_setting)[:type]).to eq(types[:string]) + expect(loader.find_by_name(:integer_setting)[:type]).to eq(types[:integer]) + expect(loader.find_by_name(:integer_setting_03)[:type]).to eq(types[:integer]) + expect(loader.find_by_name(:float_setting)[:type]).to eq(types[:float]) + expect(loader.find_by_name(:list_setting)[:type]).to eq(types[:list]) + expect(loader.find_by_name(:enum_setting)[:type]).to eq(types[:enum]) + end + + context "description locale" do + it "favors I18n.locale" do + I18n.locale = :ar + SiteSetting.default_locale = "en" + expect(loader.find_by_name(:enum_setting_02)[:opts][:description]).to eq("Arabic text") + end + + it "uses SiteSetting.default_locale if I18n.locale isn't supported" do + I18n.locale = :en + SiteSetting.default_locale = "es" + expect(loader.find_by_name(:integer_setting_02)[:opts][:description]).to eq("Spanish text") + end + + it "finds the first supported locale and uses it as a last resort" do + I18n.locale = :de + SiteSetting.default_locale = "it" + expect(loader.find_by_name(:integer_setting_02)[:opts][:description]).to eq("French text") + end + + it "doesn't set locale if no supported locale is provided" do + expect(loader.find_by_name(:integer_setting_03)[:opts][:description]).to be_nil + end + end + + context "enum setting" do + it "should never have less than 1 choices" do + choices = loader.find_by_name(:enum_setting)[:opts][:choices] + expect(choices.class).to eq(Array) + expect(choices.length).to eq(3) + + choices = loader.find_by_name(:enum_setting_02)[:opts][:choices] + expect(choices.class).to eq(Array) + expect(choices.length).to eq(1) + end + end +end diff --git a/spec/fixtures/theme_settings/invalid_settings.yaml b/spec/fixtures/theme_settings/invalid_settings.yaml new file mode 100644 index 0000000000000..184f7e5969123 --- /dev/null +++ b/spec/fixtures/theme_settings/invalid_settings.yaml @@ -0,0 +1,19 @@ +no_match_setting: + type: bool + default: "string value" + +no_default_setting: + type: string + +invalid_type_setting: + type: listt + default: "name|age|last name" + +default_out_of_range: + default: 100 + min: 1 + max: 20 + +string_default_out_of_range: + default: "abcdefg" + min: 20 diff --git a/spec/fixtures/theme_settings/valid_settings.yaml b/spec/fixtures/theme_settings/valid_settings.yaml new file mode 100644 index 0000000000000..33c9c4ed85ebd --- /dev/null +++ b/spec/fixtures/theme_settings/valid_settings.yaml @@ -0,0 +1,60 @@ +boolean_setting: true + +boolean_setting_02: + default: false + +string_setting: "string value" + +string_setting_02: + default: "string value" + min: 2 + max: 20 + +integer_setting: 51 + +integer_setting_02: + type: integer + default: 51 + min: 1 + max: 60 + description: + fr: "French text" + es: "Spanish text" + +integer_setting_03: + default: 15 + max: 60 + description: + xyz: "invalid language" + +float_setting: + default: 2.5 + min: 1.5 + max: 10 + +list_setting: + type: list + description: "help text" + default: "name|age|last name" + +enum_setting: + default: "trust level 4" + type: enum + choices: + - "trust level 0" + - "trust level 1" + +enum_setting_02: + type: enum + default: 10 + description: + en: "English text" + ar: "Arabic text" + +enum_setting_03: + type: enum + default: 1 + choices: + - 10 + - 100 + - 1000 diff --git a/spec/models/remote_theme_spec.rb b/spec/models/remote_theme_spec.rb index 1bac7d8485bf7..6ecf04e10f813 100644 --- a/spec/models/remote_theme_spec.rb +++ b/spec/models/remote_theme_spec.rb @@ -58,6 +58,7 @@ def about_json(options = {}) "common/random.html" => "I AM SILLY", "common/embedded.scss" => "EMBED", "assets/awesome.woff2" => "FAKE FONT", + "settings.yaml" => "boolean_setting: true" ) end @@ -81,7 +82,7 @@ def about_json(options = {}) expect(remote.about_url).to eq("https://www.site.com/about") expect(remote.license_url).to eq("https://www.site.com/license") - expect(@theme.theme_fields.length).to eq(6) + expect(@theme.theme_fields.length).to eq(7) mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] @@ -93,7 +94,12 @@ def about_json(options = {}) expect(mapped["0-font"]).to eq("") expect(mapped["0-name"]).to eq("sam") - expect(mapped.length).to eq(6) + expect(mapped["3-yaml"]).to eq("boolean_setting: true") + + expect(mapped.length).to eq(7) + + expect(@theme.settings.length).to eq(1) + expect(@theme.settings.first.value).to eq(true) expect(remote.remote_updated_at).to eq(time) @@ -104,6 +110,10 @@ def about_json(options = {}) File.write("#{initial_repo}/common/header.html", "I AM UPDATED") File.write("#{initial_repo}/about.json", about_json(love: "EAEAEA")) + File.write("#{initial_repo}/settings.yml", "integer_setting: 32") + `cd #{initial_repo} && git add settings.yml` + + File.delete("#{initial_repo}/settings.yaml") `cd #{initial_repo} && git commit -am "update"` time = Time.new('2001') @@ -125,8 +135,11 @@ def about_json(options = {}) expect(mapped["0-header"]).to eq("I AM UPDATED") expect(mapped["1-scss"]).to eq(scss_data) - expect(remote.remote_updated_at).to eq(time) + expect(@theme.settings.length).to eq(1) + expect(@theme.settings.first.value).to eq(32) + + expect(remote.remote_updated_at).to eq(time) end end end diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb index 798cb478197ca..0f9677780bfcf 100644 --- a/spec/models/theme_field_spec.rb +++ b/spec/models/theme_field_spec.rb @@ -3,6 +3,10 @@ require 'rails_helper' describe ThemeField do + after(:all) do + ThemeField.destroy_all + end + it "correctly generates errors for transpiled js" do html = < @@ -44,4 +48,52 @@ def create_upload_theme_field!(name) expect { create_upload_theme_field!("a42") }.not_to raise_error end + def get_fixture(type) + File.read("#{Rails.root}/spec/fixtures/theme_settings/#{type}_settings.yaml") + end + + def create_yaml_field(value) + field = ThemeField.create!(theme_id: 1, target_id: Theme.targets[:settings], name: "yaml", value: value) + field.reload + field + end + + let(:key) { "themes.settings_errors" } + + it "generates errors for bad YAML" do + yaml = "invalid_setting 5" + field = create_yaml_field(yaml) + expect(field.error).to eq(I18n.t("#{key}.invalid_yaml")) + + field.value = "valid_setting: true" + field.save! + field.reload + expect(field.error).to eq(nil) + end + + it "generates errors when default value's type doesn't match setting type" do + field = create_yaml_field(get_fixture("invalid")) + expect(field.error).to include(I18n.t("#{key}.default_not_match_type", name: "no_match_setting")) + end + + it "generates errors when no default value is passed" do + field = create_yaml_field(get_fixture("invalid")) + expect(field.error).to include(I18n.t("#{key}.default_value_missing", name: "no_default_setting")) + end + + it "generates errors when invalid type is passed" do + field = create_yaml_field(get_fixture("invalid")) + expect(field.error).to include(I18n.t("#{key}.data_type_not_a_number", name: "invalid_type_setting")) + end + + it "generates errors when default value is not within allowed range" do + field = create_yaml_field(get_fixture("invalid")) + expect(field.error).to include(I18n.t("#{key}.default_out_range", name: "default_out_of_range")) + expect(field.error).to include(I18n.t("#{key}.default_out_range", name: "string_default_out_of_range")) + end + + it "works correctly when valid yaml is provided" do + field = create_yaml_field(get_fixture("valid")) + expect(field.error).to be_nil + end end diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index 817326a1ad05c..beb26a1a9fae9 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -123,7 +123,7 @@ context "plugin api" do def transpile(html) - f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: -1, name: "after_header", value: html) + f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: 1, name: "after_header", value: html) f.value_baked end @@ -213,6 +213,19 @@ def transpile(html) end end + context "theme settings" do + it "values can 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! + + scss, _map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id) + expect(scss).to include("background-color:red") + expect(scss).to include("font-size:25px") + end + end + it 'correctly caches theme keys' do Theme.destroy_all @@ -266,4 +279,41 @@ def transpile(html) expect(user_themes).to eq([]) end + def cached_settings(key) + Theme.settings_for_client(key) # returns json + end + + it 'handles settings cache correctly' do + Theme.destroy_all + expect(cached_settings(nil)).to eq("{}") + + theme = Theme.create!(name: "awesome theme", user_id: -1) + theme.save! + expect(cached_settings(theme.key)).to eq("{}") + + theme.set_field(target: :settings, name: "yaml", value: "boolean_setting: true") + theme.save! + expect(cached_settings(theme.key)).to match(/\"boolean_setting\":true/) + + theme.settings.first.value = "false" + expect(cached_settings(theme.key)).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! + theme.add_child_theme!(child) + + json = cached_settings(theme.key) + expect(json).to match(/\"boolean_setting\":false/) + expect(json).to match(/\"integer_setting\":54/) + + expect(cached_settings(child.key)).to eq("{\"integer_setting\":54}") + + child.destroy! + json = cached_settings(theme.key) + expect(json).not_to match(/\"integer_setting\":54/) + expect(json).to match(/\"boolean_setting\":false/) + end + end