From 5e93140f85f062b0f81872892742914ec158a747 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Mon, 23 Nov 2015 16:45:05 -0500 Subject: [PATCH] FEATURE: Can override any translation via an admin interface --- .eslintignore | 1 + .../admin/adapters/site-text-type.js.es6 | 2 - .../components/expanding-text-area.js.es6 | 24 +++ .../admin/components/site-text-summary.js.es6 | 24 +++ .../controllers/admin-site-text-edit.js.es6 | 29 ++- .../controllers/admin-site-text-index.js.es6 | 40 ++++ .../admin/controllers/admin-site-text.js.es6 | 1 - .../admin/helpers/preserve-newlines.js.es6 | 3 + .../javascripts/admin/lib/autosize.js.es6 | 200 ++++++++++++++++++ .../admin/models/site-text-type.js.es6 | 2 - .../javascripts/admin/models/site-text.js.es6 | 10 +- .../admin/routes/admin-route-map.js.es6 | 3 +- .../admin/routes/admin-site-text-edit.js.es6 | 8 +- .../admin/routes/admin-site-text-index.js.es6 | 13 ++ .../admin/routes/admin-site-text.js.es6 | 5 - .../templates/components/save-controls.hbs | 6 +- .../components/site-text-summary.hbs | 5 + .../admin/templates/site-text-edit.hbs | 33 +-- .../admin/templates/site-text-index.hbs | 20 +- .../javascripts/admin/templates/site-text.hbs | 16 +- .../components/autofocus-text-field.js.es6 | 12 -- .../discourse/components/text-field.js.es6 | 7 + .../initializers/localization.js.es6 | 1 + .../discourse/models/result-set.js.es6 | 7 + .../javascripts/discourse/models/store.js.es6 | 8 +- .../javascripts/discourse/templates/topic.hbs | 3 +- app/assets/javascripts/main_include_admin.js | 1 + .../stylesheets/common/admin/admin_base.scss | 53 +++++ .../admin/site_text_types_controller.rb | 7 - .../admin/site_texts_controller.rb | 55 ++++- app/helpers/application_helper.rb | 12 -- app/mailers/user_notifications.rb | 2 +- app/models/site_text.rb | 47 ---- app/models/site_text_type.rb | 27 --- app/serializers/site_text_serializer.rb | 39 +--- app/serializers/site_text_type_serializer.rb | 17 -- app/views/static/login.html.erb | 2 +- config/initializers/i18n.rb | 6 +- config/locales/client.en.yml | 8 +- config/locales/server.en.yml | 32 --- config/routes.rb | 7 +- db/fixtures/999_topics.rb | 6 +- db/migrate/20151125194322_remove_site_text.rb | 21 ++ lib/composer_messages_finder.rb | 6 +- lib/freedom_patches/translate_accelerator.rb | 31 ++- lib/i18n/backend/discourse_i18n.rb | 18 ++ lib/site_setting_extension.rb | 1 - lib/site_text_class_methods.rb | 66 ------ lib/system_message.rb | 2 +- spec/components/discourse_i18n_spec.rb | 30 ++- .../admin/site_text_types_controller_spec.rb | 27 --- .../admin/site_texts_controller_spec.rb | 68 +++++- spec/fabricators/site_text_fabricator.rb | 14 -- spec/models/site_text_spec.rb | 83 -------- .../acceptance/admin-site-text-test.js.es6 | 41 ++++ .../acceptance/queued-posts-test.js.es6 | 75 ------- .../helpers/create-pretender.js.es6 | 82 +++---- 57 files changed, 769 insertions(+), 600 deletions(-) delete mode 100644 app/assets/javascripts/admin/adapters/site-text-type.js.es6 create mode 100644 app/assets/javascripts/admin/components/expanding-text-area.js.es6 create mode 100644 app/assets/javascripts/admin/components/site-text-summary.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-site-text-index.js.es6 delete mode 100644 app/assets/javascripts/admin/controllers/admin-site-text.js.es6 create mode 100644 app/assets/javascripts/admin/helpers/preserve-newlines.js.es6 create mode 100644 app/assets/javascripts/admin/lib/autosize.js.es6 delete mode 100644 app/assets/javascripts/admin/models/site-text-type.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-site-text-index.js.es6 delete mode 100644 app/assets/javascripts/admin/routes/admin-site-text.js.es6 create mode 100644 app/assets/javascripts/admin/templates/components/site-text-summary.hbs delete mode 100644 app/assets/javascripts/discourse/components/autofocus-text-field.js.es6 delete mode 100644 app/controllers/admin/site_text_types_controller.rb delete mode 100644 app/models/site_text.rb delete mode 100644 app/models/site_text_type.rb delete mode 100644 app/serializers/site_text_type_serializer.rb create mode 100644 db/migrate/20151125194322_remove_site_text.rb delete mode 100644 lib/site_text_class_methods.rb delete mode 100644 spec/controllers/admin/site_text_types_controller_spec.rb delete mode 100644 spec/fabricators/site_text_fabricator.rb delete mode 100644 spec/models/site_text_spec.rb create mode 100644 test/javascripts/acceptance/admin-site-text-test.js.es6 delete mode 100644 test/javascripts/acceptance/queued-posts-test.js.es6 diff --git a/.eslintignore b/.eslintignore index 87cbecfae70..8e7c2d78fce 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,7 @@ app/assets/javascripts/vendor.js app/assets/javascripts/locales/i18n.js app/assets/javascripts/defer/html-sanitizer-bundle.js app/assets/javascripts/ember-addons/ +app/assets/javascripts/admin/lib/autosize.js.es6 lib/javascripts/locale/ lib/javascripts/messageformat.js lib/javascripts/moment.js diff --git a/app/assets/javascripts/admin/adapters/site-text-type.js.es6 b/app/assets/javascripts/admin/adapters/site-text-type.js.es6 deleted file mode 100644 index b547b06f3ca..00000000000 --- a/app/assets/javascripts/admin/adapters/site-text-type.js.es6 +++ /dev/null @@ -1,2 +0,0 @@ -import CustomizationBase from 'admin/adapters/customization-base'; -export default CustomizationBase; diff --git a/app/assets/javascripts/admin/components/expanding-text-area.js.es6 b/app/assets/javascripts/admin/components/expanding-text-area.js.es6 new file mode 100644 index 00000000000..3b5a260690a --- /dev/null +++ b/app/assets/javascripts/admin/components/expanding-text-area.js.es6 @@ -0,0 +1,24 @@ +import { on, observes } from 'ember-addons/ember-computed-decorators'; +import autosize from 'admin/lib/autosize'; + +export default Ember.TextArea.extend({ + @on('didInsertElement') + _startWatching() { + Ember.run.scheduleOnce('afterRender', () => { + this.$().focus(); + autosize(this.element); + }); + }, + + @observes('value') + _updateAutosize() { + const evt = document.createEvent('Event'); + evt.initEvent('autosize:update', true, false); + this.element.dispatchEvent(evt); + }, + + @on('willDestroyElement') + _disableAutosize() { + autosize.destroy(this.$()); + } +}); diff --git a/app/assets/javascripts/admin/components/site-text-summary.js.es6 b/app/assets/javascripts/admin/components/site-text-summary.js.es6 new file mode 100644 index 00000000000..a54e615ef88 --- /dev/null +++ b/app/assets/javascripts/admin/components/site-text-summary.js.es6 @@ -0,0 +1,24 @@ +import { on } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ['site-text'], + + @on('didInsertElement') + highlightTerm() { + const term = this.get('term'); + if (term) { + this.$('.site-text-id, .site-text-value').highlight(term, {className: 'text-highlight'}); + } + this.$('.site-text-value').ellipsis(); + }, + + click() { + this.send('edit'); + }, + + actions: { + edit() { + this.sendAction('editAction', this.get('siteText')); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 index 159d26e89b5..79de9efc354 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 @@ -1,14 +1,29 @@ -export default Ember.Controller.extend({ - saved: false, +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { bufferedProperty } from 'discourse/mixins/buffered-content'; - saveDisabled: function() { - return ((!this.get('allow_blank')) && Ember.isEmpty(this.get('model.value'))); - }.property('model.iSaving', 'model.value'), +export default Ember.Controller.extend(bufferedProperty('siteText'), { + saved: false, actions: { saveChanges() { - const model = this.get('model'); - model.save(model.getProperties('value')).then(() => this.set('saved', true)); + const buffered = this.get('buffered'); + this.get('siteText').save(buffered.getProperties('value')).then(() => { + this.commitBuffer(); + this.set('saved', true); + }).catch(popupAjaxError); + }, + + revertChanges() { + this.set('saved', false); + bootbox.confirm(I18n.t('admin.site_text.revert_confirm'), result => { + if (result) { + this.get('siteText').revert().then(props => { + const buffered = this.get('buffered'); + buffered.setProperties(props); + this.commitBuffer(); + }).catch(popupAjaxError); + } + }); } } }); diff --git a/app/assets/javascripts/admin/controllers/admin-site-text-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-text-index.js.es6 new file mode 100644 index 00000000000..d5450c0d442 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-site-text-index.js.es6 @@ -0,0 +1,40 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + _q: null, + searching: false, + siteTexts: null, + preferred: false, + + queryParams: ['q'], + + @computed + q: { + set(value) { + if (Ember.isEmpty(value)) { value = null; } + this._q = value; + return value; + }, + get() { + return this._q; + } + }, + + _performSearch() { + const q = this.get('q'); + this.store.find('site-text', { q }).then(results => { + this.set('siteTexts', results); + }).finally(() => this.set('searching', false)); + }, + + actions: { + edit(siteText) { + this.transitionToRoute('adminSiteText.edit', siteText.get('id')); + }, + + search() { + this.set('searching', true); + Ember.run.debounce(this, this._performSearch, 400); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-site-text.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-text.js.es6 deleted file mode 100644 index 24c4c051390..00000000000 --- a/app/assets/javascripts/admin/controllers/admin-site-text.js.es6 +++ /dev/null @@ -1 +0,0 @@ -export default Ember.ArrayController.extend(); diff --git a/app/assets/javascripts/admin/helpers/preserve-newlines.js.es6 b/app/assets/javascripts/admin/helpers/preserve-newlines.js.es6 new file mode 100644 index 00000000000..aeb9f30b377 --- /dev/null +++ b/app/assets/javascripts/admin/helpers/preserve-newlines.js.es6 @@ -0,0 +1,3 @@ +Em.Handlebars.helper('preserve-newlines', str => { + return new Handlebars.SafeString(Discourse.Utilities.escapeExpression(str).replace(/\n/g, "
")); +}); diff --git a/app/assets/javascripts/admin/lib/autosize.js.es6 b/app/assets/javascripts/admin/lib/autosize.js.es6 new file mode 100644 index 00000000000..dbdb858093c --- /dev/null +++ b/app/assets/javascripts/admin/lib/autosize.js.es6 @@ -0,0 +1,200 @@ +const set = (typeof Set === "function") ? new Set() : (function () { + const list = []; + + return { + has(key) { + return Boolean(list.indexOf(key) > -1); + }, + add(key) { + list.push(key); + }, + delete(key) { + list.splice(list.indexOf(key), 1); + }, + }; +})(); + +function assign(ta, {setOverflowX = true, setOverflowY = true} = {}) { + if (!ta || !ta.nodeName || ta.nodeName !== 'TEXTAREA' || set.has(ta)) return; + + let heightOffset = null; + let overflowY = null; + let clientWidth = ta.clientWidth; + + function init() { + const style = window.getComputedStyle(ta, null); + + overflowY = style.overflowY; + + if (style.resize === 'vertical') { + ta.style.resize = 'none'; + } else if (style.resize === 'both') { + ta.style.resize = 'horizontal'; + } + + if (style.boxSizing === 'content-box') { + heightOffset = -(parseFloat(style.paddingTop)+parseFloat(style.paddingBottom)); + } else { + heightOffset = parseFloat(style.borderTopWidth)+parseFloat(style.borderBottomWidth); + } + // Fix when a textarea is not on document body and heightOffset is Not a Number + if (isNaN(heightOffset)) { + heightOffset = 0; + } + + update(); + } + + function changeOverflow(value) { + { + // Chrome/Safari-specific fix: + // When the textarea y-overflow is hidden, Chrome/Safari do not reflow the text to account for the space + // made available by removing the scrollbar. The following forces the necessary text reflow. + const width = ta.style.width; + ta.style.width = '0px'; + // Force reflow: + /* jshint ignore:start */ + ta.offsetWidth; + /* jshint ignore:end */ + ta.style.width = width; + } + + overflowY = value; + + if (setOverflowY) { + ta.style.overflowY = value; + } + + resize(); + } + + function resize() { + const htmlTop = window.pageYOffset; + const bodyTop = document.body.scrollTop; + const originalHeight = ta.style.height; + + ta.style.height = 'auto'; + + let endHeight = ta.scrollHeight+heightOffset; + + if (ta.scrollHeight === 0) { + // If the scrollHeight is 0, then the element probably has display:none or is detached from the DOM. + ta.style.height = originalHeight; + return; + } + + ta.style.height = endHeight+'px'; + + // used to check if an update is actually necessary on window.resize + clientWidth = ta.clientWidth; + + // prevents scroll-position jumping + document.documentElement.scrollTop = htmlTop; + document.body.scrollTop = bodyTop; + } + + function update() { + const startHeight = ta.style.height; + + resize(); + + const style = window.getComputedStyle(ta, null); + + if (style.height !== ta.style.height) { + if (overflowY !== 'visible') { + changeOverflow('visible'); + } + } else { + if (overflowY !== 'hidden') { + changeOverflow('hidden'); + } + } + + if (startHeight !== ta.style.height) { + const evt = document.createEvent('Event'); + evt.initEvent('autosize:resized', true, false); + ta.dispatchEvent(evt); + } + } + + const pageResize = () => { + if (ta.clientWidth !== clientWidth) { + update(); + } + }; + + const destroy = style => { + window.removeEventListener('resize', pageResize, false); + ta.removeEventListener('input', update, false); + ta.removeEventListener('keyup', update, false); + ta.removeEventListener('autosize:destroy', destroy, false); + ta.removeEventListener('autosize:update', update, false); + set.delete(ta); + + Object.keys(style).forEach(key => { + ta.style[key] = style[key]; + }); + }.bind(ta, { + height: ta.style.height, + resize: ta.style.resize, + overflowY: ta.style.overflowY, + overflowX: ta.style.overflowX, + wordWrap: ta.style.wordWrap, + }); + + ta.addEventListener('autosize:destroy', destroy, false); + + // IE9 does not fire onpropertychange or oninput for deletions, + // so binding to onkeyup to catch most of those events. + // There is no way that I know of to detect something like 'cut' in IE9. + if ('onpropertychange' in ta && 'oninput' in ta) { + ta.addEventListener('keyup', update, false); + } + + window.addEventListener('resize', pageResize, false); + ta.addEventListener('input', update, false); + ta.addEventListener('autosize:update', update, false); + set.add(ta); + + if (setOverflowX) { + ta.style.overflowX = 'hidden'; + ta.style.wordWrap = 'break-word'; + } + + init(); +} + +function exportDestroy(ta) { + if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) return; + const evt = document.createEvent('Event'); + evt.initEvent('autosize:destroy', true, false); + ta.dispatchEvent(evt); +} + +function exportUpdate(ta) { + if (!(ta && ta.nodeName && ta.nodeName === 'TEXTAREA')) return; + const evt = document.createEvent('Event'); + evt.initEvent('autosize:update', true, false); + ta.dispatchEvent(evt); +} + +let autosize = (el, options) => { + if (el) { + Array.prototype.forEach.call(el.length ? el : [el], x => assign(x, options)); + } + return el; +}; +autosize.destroy = el => { + if (el) { + Array.prototype.forEach.call(el.length ? el : [el], exportDestroy); + } + return el; +}; +autosize.update = el => { + if (el) { + Array.prototype.forEach.call(el.length ? el : [el], exportUpdate); + } + return el; +}; + +export default autosize; diff --git a/app/assets/javascripts/admin/models/site-text-type.js.es6 b/app/assets/javascripts/admin/models/site-text-type.js.es6 deleted file mode 100644 index 7cb1171e909..00000000000 --- a/app/assets/javascripts/admin/models/site-text-type.js.es6 +++ /dev/null @@ -1,2 +0,0 @@ -import RestModel from 'discourse/models/rest'; -export default RestModel.extend(); diff --git a/app/assets/javascripts/admin/models/site-text.js.es6 b/app/assets/javascripts/admin/models/site-text.js.es6 index edbaf2a4447..0d18ad7ea82 100644 --- a/app/assets/javascripts/admin/models/site-text.js.es6 +++ b/app/assets/javascripts/admin/models/site-text.js.es6 @@ -1,8 +1,10 @@ import RestModel from 'discourse/models/rest'; +const { getProperties } = Ember; export default RestModel.extend({ - markdown: Em.computed.equal('format', 'markdown'), - plainText: Em.computed.equal('format', 'plain'), - html: Em.computed.equal('format', 'html'), - css: Em.computed.equal('format', 'css'), + revert() { + return Discourse.ajax(`/admin/customize/site_texts/${this.get('id')}`, { + method: 'DELETE' + }).then(result => getProperties(result.site_text, 'value', 'can_revert')); + } }); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index b7c7144187c..406a0011706 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -22,8 +22,9 @@ export default { }); this.resource('adminSiteText', { path: '/site_texts' }, function() { - this.route('edit', {path: '/:text_type'}); + this.route('edit', { path: '/:id' }); }); + this.resource('adminUserFields', { path: '/user_fields' }); this.resource('adminEmojis', { path: '/emojis' }); this.resource('adminPermalinks', { path: '/permalinks' }); diff --git a/app/assets/javascripts/admin/routes/admin-site-text-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-site-text-edit.js.es6 index 847746d0396..2774f0aec9e 100644 --- a/app/assets/javascripts/admin/routes/admin-site-text-edit.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-site-text-edit.js.es6 @@ -1,5 +1,9 @@ -export default Discourse.Route.extend({ +export default Ember.Route.extend({ model(params) { - return this.store.find('site-text', params.text_type); + return this.store.find('site-text', params.id); + }, + + setupController(controller, siteText) { + controller.setProperties({ siteText, saved: false }); } }); diff --git a/app/assets/javascripts/admin/routes/admin-site-text-index.js.es6 b/app/assets/javascripts/admin/routes/admin-site-text-index.js.es6 new file mode 100644 index 00000000000..e312d389dfc --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-site-text-index.js.es6 @@ -0,0 +1,13 @@ +export default Ember.Route.extend({ + queryParams: { + q: { replace: true } + }, + + model(params) { + return this.store.find('site-text', { q: params.q }); + }, + + setupController(controller, model) { + controller.set('siteTexts', model); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-site-text.js.es6 b/app/assets/javascripts/admin/routes/admin-site-text.js.es6 deleted file mode 100644 index f69c3c3954b..00000000000 --- a/app/assets/javascripts/admin/routes/admin-site-text.js.es6 +++ /dev/null @@ -1,5 +0,0 @@ -export default Discourse.Route.extend({ - model() { - return this.store.findAll('site-text-type'); - } -}); diff --git a/app/assets/javascripts/admin/templates/components/save-controls.hbs b/app/assets/javascripts/admin/templates/components/save-controls.hbs index e8e13cb9c05..00ac91331fa 100644 --- a/app/assets/javascripts/admin/templates/components/save-controls.hbs +++ b/app/assets/javascripts/admin/templates/components/save-controls.hbs @@ -1,5 +1,7 @@ -{{d-button action="saveChanges" disabled=buttonDisabled label=savingText class="btn-primary"}} +{{d-button action="saveChanges" disabled=buttonDisabled label=savingText class="btn-primary save-changes"}} {{yield}}
- {{#if saved}}{{i18n 'saved'}}{{/if}} + {{#if saved}} +
{{i18n 'saved'}}
+ {{/if}}
diff --git a/app/assets/javascripts/admin/templates/components/site-text-summary.hbs b/app/assets/javascripts/admin/templates/components/site-text-summary.hbs new file mode 100644 index 00000000000..bf5ee8c7f03 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/site-text-summary.hbs @@ -0,0 +1,5 @@ +{{d-button label="admin.site_text.edit" class='edit' action="edit"}} +

{{siteText.id}}

+
{{siteText.value}}
+ +
diff --git a/app/assets/javascripts/admin/templates/site-text-edit.hbs b/app/assets/javascripts/admin/templates/site-text-edit.hbs index 5ff141d0c93..5e02eb07316 100644 --- a/app/assets/javascripts/admin/templates/site-text-edit.hbs +++ b/app/assets/javascripts/admin/templates/site-text-edit.hbs @@ -1,17 +1,20 @@ -

{{model.title}}

-

{{model.description}}

+
-{{#if model.markdown}} - {{d-editor value=model.value}} -{{/if}} -{{#if model.plainText}} - {{textarea value=model.value class="plain"}} -{{/if}} -{{#if model.html}} - {{ace-editor content=model.value mode="html"}} -{{/if}} -{{#if model.css}} - {{ace-editor content=model.value mode="css"}} -{{/if}} +
+

{{siteText.id}}

+
-{{save-controls model=model action="saveChanges" saveDisabled=saveDisabled saved=saved}} + {{expanding-text-area value=buffered.value rows="1" class="site-text-value"}} + + {{#save-controls model=siteText action="saveChanges" saved=saved}} + {{#if siteText.can_revert}} + {{d-button action="revertChanges" label="admin.site_text.revert" class="revert-site-text"}} + {{/if}} + {{/save-controls}} + + {{#link-to 'adminSiteText.index' class="go-back"}} + {{fa-icon 'arrow-left'}} + {{i18n 'admin.site_text.go_back'}} + {{/link-to}} + +
diff --git a/app/assets/javascripts/admin/templates/site-text-index.hbs b/app/assets/javascripts/admin/templates/site-text-index.hbs index 5a448def37a..85b007dcc97 100644 --- a/app/assets/javascripts/admin/templates/site-text-index.hbs +++ b/app/assets/javascripts/admin/templates/site-text-index.hbs @@ -1 +1,19 @@ -

{{i18n 'admin.site_text.none'}}

+
+

{{i18n "admin.site_text.description"}}

+ + {{text-field value=q + placeholderKey="admin.site_text.search" + class="no-blur site-text-search" + autofocus="true" + keyUpAction="search"}} +
+ +{{#conditional-loading-spinner condition=searching}} + {{#unless siteTexts.findArgs.q}} +

{{i18n "admin.site_text.recommended"}}

+ {{/unless}} + + {{#each siteTexts as |siteText|}} + {{site-text-summary siteText=siteText editAction="edit" term=q}} + {{/each}} +{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/site-text.hbs b/app/assets/javascripts/admin/templates/site-text.hbs index 25924f99e97..e12a542d794 100644 --- a/app/assets/javascripts/admin/templates/site-text.hbs +++ b/app/assets/javascripts/admin/templates/site-text.hbs @@ -1,15 +1,3 @@ -
-
-
    - {{#each c in model}} -
  • - {{#link-to 'adminSiteText.edit' c.text_type}}{{c.title}}{{/link-to}} -
  • - {{/each}} -
-
- -
- {{outlet}} -
+
+ {{outlet}}
diff --git a/app/assets/javascripts/discourse/components/autofocus-text-field.js.es6 b/app/assets/javascripts/discourse/components/autofocus-text-field.js.es6 deleted file mode 100644 index dd1ccd9871b..00000000000 --- a/app/assets/javascripts/discourse/components/autofocus-text-field.js.es6 +++ /dev/null @@ -1,12 +0,0 @@ -import { on } from "ember-addons/ember-computed-decorators"; - -export default Ember.TextField.extend({ - - @on("didInsertElement") - becomeFocused() { - const input = this.get("element"); - input.focus(); - input.selectionStart = input.selectionEnd = input.value.length; - } - -}); diff --git a/app/assets/javascripts/discourse/components/text-field.js.es6 b/app/assets/javascripts/discourse/components/text-field.js.es6 index a9246efa7db..cf572dd121c 100644 --- a/app/assets/javascripts/discourse/components/text-field.js.es6 +++ b/app/assets/javascripts/discourse/components/text-field.js.es6 @@ -6,5 +6,12 @@ export default Ember.TextField.extend({ @computed("placeholderKey") placeholder(placeholderKey) { return placeholderKey ? I18n.t(placeholderKey) : ""; + }, + + keyUp() { + const act = this.get('keyUpAction'); + if (act) { + this.sendAction('keyUpAction'); + } } }); diff --git a/app/assets/javascripts/discourse/initializers/localization.js.es6 b/app/assets/javascripts/discourse/initializers/localization.js.es6 index f59a680b0d9..0ad607d3f4a 100644 --- a/app/assets/javascripts/discourse/initializers/localization.js.es6 +++ b/app/assets/javascripts/discourse/initializers/localization.js.es6 @@ -12,6 +12,7 @@ export default { const overrides = PreloadStore.get('translationOverrides') || {}; Object.keys(overrides).forEach(k => { const v = overrides[k]; + k = k.replace('admin_js', 'js'); const segs = k.split('.'); let node = I18n.translations[I18n.locale]; diff --git a/app/assets/javascripts/discourse/models/result-set.js.es6 b/app/assets/javascripts/discourse/models/result-set.js.es6 index 754fdadd200..6d1b558a572 100644 --- a/app/assets/javascripts/discourse/models/result-set.js.es6 +++ b/app/assets/javascripts/discourse/models/result-set.js.es6 @@ -4,6 +4,13 @@ export default Ember.ArrayProxy.extend({ totalRows: 0, refreshing: false, + content: null, + loadMoreUrl: null, + refreshUrl: null, + findArgs: null, + store: null, + __type: null, + canLoadMore: function() { return this.get('length') < this.get('totalRows'); }.property('totalRows', 'length'), diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6 index 57d60b0bbc3..b29b1033065 100644 --- a/app/assets/javascripts/discourse/models/store.js.es6 +++ b/app/assets/javascripts/discourse/models/store.js.es6 @@ -63,7 +63,7 @@ export default Ember.Object.extend({ _hydrateFindResults(result, type, findArgs) { if (typeof findArgs === "object") { - return this._resultSet(type, result); + return this._resultSet(type, result, findArgs); } else { return this._hydrate(type, result[Ember.String.underscore(type)], result); } @@ -81,7 +81,7 @@ export default Ember.Object.extend({ }, find(type, findArgs, opts) { - return this.adapterFor(type).find(this, type, findArgs, opts).then((result) => { + return this.adapterFor(type).find(this, type, findArgs, opts).then(result => { return this._hydrateFindResults(result, type, findArgs, opts); }); }, @@ -142,14 +142,14 @@ export default Ember.Object.extend({ }); }, - _resultSet(type, result) { + _resultSet(type, result, findArgs) { const typeName = Ember.String.underscore(this.pluralize(type)), content = result[typeName].map(obj => this._hydrate(type, obj, result)), totalRows = result["total_rows_" + typeName] || content.length, loadMoreUrl = result["load_more_" + typeName], refreshUrl = result['refresh_' + typeName]; - return ResultSet.create({ content, totalRows, loadMoreUrl, refreshUrl, store: this, __type: type }); + return ResultSet.create({ content, totalRows, loadMoreUrl, refreshUrl, findArgs, store: this, __type: type }); }, _build(type, obj) { diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 26f5d7ad218..f26ff34e2a0 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -16,7 +16,8 @@ {{#if model.isPrivateMessage}} {{fa-icon "envelope"}} {{/if}} - {{autofocus-text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length}} + + {{text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length autofocus="true"}} {{#if showCategoryChooser}}
{{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}} diff --git a/app/assets/javascripts/main_include_admin.js b/app/assets/javascripts/main_include_admin.js index 7816da8382a..1217131b4fd 100644 --- a/app/assets/javascripts/main_include_admin.js +++ b/app/assets/javascripts/main_include_admin.js @@ -9,6 +9,7 @@ //= require admin/routes/admin-email-logs //= require admin/controllers/admin-email-skipped //= require discourse/lib/export-result +//= require_tree ./admin/lib //= require_tree ./admin //= require resumable.js diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 8a3bdd297e7..9bec8c8c3a8 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -43,6 +43,59 @@ td.flaggers td { border-top: none; } +.site-texts { + .search-area { + margin-bottom: 2em; + p { + margin-top: 0; + } + + input { + padding: 0.5em; + font-size: 1em; + width: 50%; + } + } + .text-highlight { + font-weight: bold; + } + + .site-text { + cursor: pointer; + border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); + margin-bottom: 0.5em; + + h3 { + font-weight: normal; + font-size: 1.1em; + } + + button.edit { + float: right; + } + .site-text-value { + margin: 0.5em 5em 0.5em 0; + max-height: 100px; + color: dark-light-diff($primary, $secondary, 40%, -10%); + } + + } + + .edit-site-text { + textarea { + width: 80%; + } + + .save-messages, .title { + margin-bottom: 1em; + } + + .go-back { + margin-top: 1em; + } + } +} + .content-list li a span.count { font-size: 0.857em; float: right; diff --git a/app/controllers/admin/site_text_types_controller.rb b/app/controllers/admin/site_text_types_controller.rb deleted file mode 100644 index f13e1ce704e..00000000000 --- a/app/controllers/admin/site_text_types_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Admin::SiteTextTypesController < Admin::AdminController - - def index - render_serialized(SiteText.text_types, SiteTextTypeSerializer, root: 'site_text_types') - end - -end diff --git a/app/controllers/admin/site_texts_controller.rb b/app/controllers/admin/site_texts_controller.rb index 416d9901861..0a70519f25b 100644 --- a/app/controllers/admin/site_texts_controller.rb +++ b/app/controllers/admin/site_texts_controller.rb @@ -1,21 +1,54 @@ class Admin::SiteTextsController < Admin::AdminController + def self.preferred_keys + ['system_messages.usage_tips.text_body_template', + 'education.new-topic', + 'education.new-reply', + 'login_required.welcome_message'] + end + + def index + if params[:q].blank? + results = self.class.preferred_keys.map {|k| {id: k, value: I18n.t(k) }} + else + results = [] + translations = I18n.search(params[:q]) + translations.each do |k, v| + results << {id: k, value: v} + end + results.sort! do |x, y| + (x[:id].size + x[:value].size) <=> (y[:id].size + y[:value].size) + end + end + + render_serialized(results[0..50], SiteTextSerializer, root: 'site_texts', rest_serializer: true) + end + def show - site_text = SiteText.find_or_new(params[:id].to_s) - render_serialized(site_text, SiteTextSerializer, root: 'site_text') + site_text = find_site_text + render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true) end def update - site_text = SiteText.find_or_new(params[:id].to_s) + site_text = find_site_text + site_text[:value] = params[:site_text][:value] - # Updating to nothing is the same as removing it - if params[:site_text][:value].present? - site_text.value = params[:site_text][:value] - site_text.save! - else - site_text.destroy + TranslationOverride.upsert!(I18n.locale, site_text[:id], site_text[:value]) + render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true) + end + + def revert + site_text = find_site_text + TranslationOverride.revert!(I18n.locale, site_text[:id]) + site_text = find_site_text + render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true) + end + + protected + + def find_site_text + raise Discourse::NotFound unless I18n.exists?(params[:id]) + {id: params[:id], value: I18n.t(params[:id]) } end - render_serialized(site_text, SiteTextSerializer, root: 'site_text') - end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 022141a73ab..9a12e5dbb0d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -149,18 +149,6 @@ module ApplicationHelper result.join("\n") end - # Look up site content for a key. If the key is blank, you can supply a block and that - # will be rendered instead. - def markdown_content(key, replacements=nil) - result = PrettyText.cook(SiteText.text_for(key, replacements || {})).html_safe - if result.blank? && block_given? - yield - nil - else - result - end - end - def application_logo_url @application_logo_url ||= (mobile_view? && SiteSetting.mobile_logo_url) || SiteSetting.logo_url end diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index aa2aa049292..4b7b58afa52 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -18,7 +18,7 @@ class UserNotifications < ActionMailer::Base build_email(user.email, template: 'user_notifications.signup_after_approval', email_token: opts[:email_token], - new_user_tips: SiteText.text_for(:usage_tips, base_url: Discourse.base_url)) + new_user_tips: I18n.t('system_messages.usage_tips.text_body_template', base_url: Discourse.base_url)) end def authorize_email(user, opts={}) diff --git a/app/models/site_text.rb b/app/models/site_text.rb deleted file mode 100644 index 82a0f37407b..00000000000 --- a/app/models/site_text.rb +++ /dev/null @@ -1,47 +0,0 @@ -require_dependency 'site_text_type' -require_dependency 'site_text_class_methods' -require_dependency 'distributed_cache' - -class SiteText < ActiveRecord::Base - extend SiteTextClassMethods - - self.primary_key = 'text_type' - - validates_presence_of :value - - after_save do - SiteText.text_for_cache.clear - end - - after_destroy do - SiteText.text_for_cache.clear - end - - def self.formats - @formats ||= Enum.new(:plain, :markdown, :html, :css) - end - - add_text_type :usage_tips, default_18n_key: 'system_messages.usage_tips.text_body_template' - add_text_type :education_new_topic, default_18n_key: 'education.new-topic' - add_text_type :education_new_reply, default_18n_key: 'education.new-reply' - add_text_type :login_required_welcome_message, default_18n_key: 'login_required.welcome_message' - - def site_text_type - @site_text_type ||= SiteText.find_text_type(text_type) - end - -end - -# == Schema Information -# -# Table name: site_texts -# -# text_type :string(255) not null, primary key -# value :text not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_site_texts_on_text_type (text_type) UNIQUE -# diff --git a/app/models/site_text_type.rb b/app/models/site_text_type.rb deleted file mode 100644 index 3b505e870cf..00000000000 --- a/app/models/site_text_type.rb +++ /dev/null @@ -1,27 +0,0 @@ -class SiteTextType - - attr_accessor :text_type, :format - - def initialize(text_type, format, opts=nil) - @opts = opts || {} - @text_type = text_type - @format = format - end - - def title - I18n.t("content_types.#{text_type}.title") - end - - def description - I18n.t("content_types.#{text_type}.description") - end - - def allow_blank? - !!@opts[:allow_blank] - end - - def default_text - @opts[:default_18n_key].present? ? I18n.t(@opts[:default_18n_key]) : "" - end - -end diff --git a/app/serializers/site_text_serializer.rb b/app/serializers/site_text_serializer.rb index fb8e7920318..0bd13d7520a 100644 --- a/app/serializers/site_text_serializer.rb +++ b/app/serializers/site_text_serializer.rb @@ -1,39 +1,20 @@ class SiteTextSerializer < ApplicationSerializer - - attributes :id, - :text_type, - :title, - :description, - :value, - :format, - :allow_blank? + attributes :id, :value, :can_revert? def id - text_type - end - - def title - object.site_text_type.title - end - - def text_type - object.text_type - end - - def description - object.site_text_type.description - end - - def format - object.site_text_type.format + object[:id] end def value - return object.value if object.value.present? - object.site_text_type.default_text + object[:value] end - def allow_blank? - object.site_text_type.allow_blank? + def can_revert? + current_val = value + + I18n.overrides_disabled do + return I18n.t(object[:id]) != current_val + end end end + diff --git a/app/serializers/site_text_type_serializer.rb b/app/serializers/site_text_type_serializer.rb deleted file mode 100644 index 9c6f5f4164a..00000000000 --- a/app/serializers/site_text_type_serializer.rb +++ /dev/null @@ -1,17 +0,0 @@ -class SiteTextTypeSerializer < ApplicationSerializer - - attributes :id, :text_type, :title - - def id - text_type - end - - def text_type - object.text_type - end - - def title - object.title - end - -end diff --git a/app/views/static/login.html.erb b/app/views/static/login.html.erb index 6e840e6e9ab..c484d05e31b 100644 --- a/app/views/static/login.html.erb +++ b/app/views/static/login.html.erb @@ -1,3 +1,3 @@ <% if SiteSetting.login_required %> - <%= markdown_content(:login_required_welcome_message) %> + <%= PrettyText.cook(I18n.t('login_required.welcome_message')).html_safe %> <% end %> diff --git a/config/initializers/i18n.rb b/config/initializers/i18n.rb index 5d1ecc6b006..37a0dd050cc 100644 --- a/config/initializers/i18n.rb +++ b/config/initializers/i18n.rb @@ -3,6 +3,8 @@ require 'i18n/backend/discourse_i18n' I18n.backend = I18n::Backend::DiscourseI18n.new I18n.config.missing_interpolation_argument_handler = proc { throw(:exception) } -I18n.reload! +I18n.init_accelerator! -MessageBus.subscribe("/i18n-flush") { I18n.reload! } +unless Rails.env.test? + MessageBus.subscribe("/i18n-flush") { I18n.reload! } +end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0309d2a9c59..ad978826557 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2482,8 +2482,14 @@ en: dropdown: "Dropdown" site_text: - none: "Choose a type of content to begin editing." + description: "You can customize any of the text on your forum. Please start by searching below:" + search: "Search for the text you'd like to edit" title: 'Text Content' + edit: 'edit' + revert: "Revert Changes" + revert_confirm: "Are you sure you want to revert your changes?" + go_back: "Back to Search" + recommended: "We recommend customizing the following text to suit your needs:" site_settings: show_overriden: 'Only show overridden' diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 8fec99b07a9..045f96c8639 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -748,38 +748,6 @@ en: notification_email_warning: "Notification emails are not being sent from a valid email address on your domain; email delivery will be erratic and unreliable. Please set notification_email to a valid local email address in Site Settings." subfolder_ends_in_slash: "Your subfolder setup is incorrect; the DISCOURSE_RELATIVE_URL_ROOT ends in a slash." - content_types: - education_new_reply: - title: "New User Education: First Replies" - description: "Pop up just-in-time guidance automatically displayed above the composer when new users begin typing their first two new replies." - education_new_topic: - title: "New User Education: First Topics" - description: "Pop up just-in-time guidance automatically displayed above the composer when new users begin typing their first two new topics." - usage_tips: - title: "New User Guidance" - description: "Guidance and essential information for new users." - welcome_user: - title: "Welcome: New User" - description: "A message automatically sent to all new users when they sign up." - welcome_invite: - title: "Welcome: Invited User" - description: "A message automatically sent to all new invited users when they accept the invitation from another user to participate." - login_required_welcome_message: - title: "Login Required: Welcome Message" - description: "Welcome message that is displayed to logged out users when the 'login required' setting is enabled." - login_required: - title: "Login Required: Homepage" - description: "The text displayed for unauthorized users when login is required on the site." - head: - title: "HTML head" - description: "HTML that will be inserted inside the tags." - top: - title: "Top of the pages" - description: "HTML that will be added at the top of every page (after the header, before the navigation or the topic title)." - bottom: - title: "Bottom of the pages" - description: "HTML that will be added before the tag." - site_settings: censored_words: "Words that will be automatically replaced with ■■■■" delete_old_hidden_posts: "Auto-delete any hidden posts that stay hidden for more than 30 days." diff --git a/config/routes.rb b/config/routes.rb index 57486b0dcd7..ad61e068234 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -154,12 +154,15 @@ Discourse::Application.routes.draw do post "flags/defer/:id" => "flags#defer" resources :site_customizations, constraints: AdminConstraint.new scope "/customize" do - resources :site_texts, constraints: AdminConstraint.new - resources :site_text_types, constraints: AdminConstraint.new resources :user_fields, constraints: AdminConstraint.new resources :emojis, constraints: AdminConstraint.new # They have periods in their URLs often: + get 'site_texts' => 'site_texts#index' + match 'site_texts/(:id)' => 'site_texts#show', :constraints => { :id => /[0-9a-z\_\.\-]+/ }, via: :get + match 'site_texts/(:id)' => 'site_texts#update', :constraints => { :id => /[0-9a-z\_\.\-]+/ }, via: :put + match 'site_texts/(:id)' => 'site_texts#revert', :constraints => { :id => /[0-9a-z\_\.\-]+/ }, via: :delete + get 'email_templates' => 'email_templates#index' match 'email_templates/(:id)' => 'email_templates#show', :constraints => { :id => /[0-9a-z\_\.]+/ }, via: :get match 'email_templates/(:id)' => 'email_templates#update', :constraints => { :id => /[0-9a-z\_\.]+/ }, via: :put diff --git a/db/fixtures/999_topics.rb b/db/fixtures/999_topics.rb index 0f813877b3c..cfe48615bcc 100644 --- a/db/fixtures/999_topics.rb +++ b/db/fixtures/999_topics.rb @@ -32,11 +32,9 @@ unless Rails.env.test? company_name: "company_short_name" }) - create_static_page_topic('guidelines_topic_id', 'guidelines_topic.title', "guidelines_topic.body", - (SiteText.text_for(:faq) rescue nil), staff, "guidelines") + create_static_page_topic('guidelines_topic_id', 'guidelines_topic.title', "guidelines_topic.body", nil, staff, "guidelines") - create_static_page_topic('privacy_topic_id', 'privacy_topic.title', "privacy_topic.body", - (SiteText.text_for(:privacy_policy) rescue nil), staff, "privacy policy") + create_static_page_topic('privacy_topic_id', 'privacy_topic.title', "privacy_topic.body", nil, staff, "privacy policy") end if seed_welcome_topics diff --git a/db/migrate/20151125194322_remove_site_text.rb b/db/migrate/20151125194322_remove_site_text.rb new file mode 100644 index 00000000000..9f1b4b6a17b --- /dev/null +++ b/db/migrate/20151125194322_remove_site_text.rb @@ -0,0 +1,21 @@ +class RemoveSiteText < ActiveRecord::Migration + def change + execute "INSERT INTO translation_overrides (locale, translation_key, value, created_at, updated_at) + SELECT '#{I18n.locale}', + CASE + WHEN text_type = 'usage_tips' THEN 'system_messages.usage_tips.text_body_template' + WHEN text_type = 'education_new_topic' THEN 'education.new-topic' + WHEN text_type = 'education_new_reply' THEN 'education.new-reply' + WHEN text_type = 'login_required_welcome_message' THEN 'login_required.welcome_message' + END, + value, + created_at, + updated_at + FROM site_texts + WHERE text_type in ('usage_tips', + 'education_new_topic', + 'education_new_reply', + 'login_required_welcome_message')" + drop_table :site_texts + end +end diff --git a/lib/composer_messages_finder.rb b/lib/composer_messages_finder.rb index 27e7db68999..68edfb76cca 100644 --- a/lib/composer_messages_finder.rb +++ b/lib/composer_messages_finder.rb @@ -18,10 +18,10 @@ class ComposerMessagesFinder def check_education_message if creating_topic? count = @user.created_topic_count - education_key = :education_new_topic + education_key = 'education.new_topic' else count = @user.post_count - education_key = :education_new_reply + education_key = 'education.new_reply' end if count < SiteSetting.educate_until_posts @@ -29,7 +29,7 @@ class ComposerMessagesFinder return { templateName: 'composer/education', wait_for_typing: true, - body: PrettyText.cook(SiteText.text_for(education_key, education_posts_text: education_posts_text)) + body: PrettyText.cook(I18n.t(education_key, education_posts_text: education_posts_text)) } end diff --git a/lib/freedom_patches/translate_accelerator.rb b/lib/freedom_patches/translate_accelerator.rb index 4bdeaf5cd8d..46b5973ed5b 100644 --- a/lib/freedom_patches/translate_accelerator.rb +++ b/lib/freedom_patches/translate_accelerator.rb @@ -13,14 +13,18 @@ module I18n # this accelerates translation a tiny bit (halves the time it takes) class << self alias_method :translate_no_cache, :translate + alias_method :exists_no_cache?, :exists? alias_method :reload_no_cache!, :reload! LRU_CACHE_SIZE = 300 + def init_accelerator! + @overrides_enabled = true + reload! + end + def reload! @loaded_locales = [] @cache = nil - - @overrides_enabled = true @overrides_by_site = {} reload_no_cache! @@ -47,6 +51,21 @@ module I18n backend.fallbacks(locale).each {|l| ensure_loaded!(l) } end + def search(query, opts=nil) + load_locale(config.locale) unless @loaded_locales.include?(config.locale) + opts ||= {} + + target = opts[:backend] || backend + results = target.search(config.locale, query) + + regexp = /#{query}/i + (overrides_by_locale || {}).each do |k, v| + results.delete(k) + results[k] = v if (k =~ regexp || v =~ regexp) + end + results + end + def ensure_loaded!(locale) @loaded_locales ||= [] load_locale(locale) unless @loaded_locales.include?(locale) @@ -94,7 +113,7 @@ module I18n end def client_overrides_json - client_json = (overrides_by_locale || {}).select {|k, _| k.starts_with?('js.')} + client_json = (overrides_by_locale || {}).select {|k, _| k.starts_with?('js.') || k.starts_with?('admin_js.')} MultiJson.dump(client_json) end @@ -118,5 +137,11 @@ module I18n end alias_method :t, :translate + + def exists?(*args) + load_locale(config.locale) unless @loaded_locales.include?(config.locale) + exists_no_cache?(*args) + end + end end diff --git a/lib/i18n/backend/discourse_i18n.rb b/lib/i18n/backend/discourse_i18n.rb index a59b252b42d..f8f072a3415 100644 --- a/lib/i18n/backend/discourse_i18n.rb +++ b/lib/i18n/backend/discourse_i18n.rb @@ -41,7 +41,25 @@ module I18n false end + def search(locale, query) + find_results(/#{query}/i, {}, translations[locale]) + end + protected + def find_results(regexp, results, translations, path=nil) + return results if translations.blank? + + translations.each do |k_sym, v| + k = k_sym.to_s + key_path = path ? "#{path}.#{k}" : k + if v.is_a?(String) + results[key_path] = v if key_path =~ regexp || v =~ regexp + elsif v.is_a?(Hash) + find_results(regexp, results, v, key_path) + end + end + results + end def lookup(locale, key, scope = [], options = {}) # Support interpolation and pluralization of overrides diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb index 437cc511935..7b85f9c6eeb 100644 --- a/lib/site_setting_extension.rb +++ b/lib/site_setting_extension.rb @@ -371,7 +371,6 @@ module SiteSettingExtension protected def clear_cache! - SiteText.text_for_cache.clear Rails.cache.delete(SiteSettingExtension.client_settings_cache_key) Site.clear_anon_cache! end diff --git a/lib/site_text_class_methods.rb b/lib/site_text_class_methods.rb deleted file mode 100644 index 7ff9b0a1c22..00000000000 --- a/lib/site_text_class_methods.rb +++ /dev/null @@ -1,66 +0,0 @@ -module SiteTextClassMethods - - def text_types - @types || [] - end - - def find_text_type(ct) - SiteText.text_types.find {|t| t.text_type == ct.to_sym} - end - - def add_text_type(text_type, opts=nil) - opts ||= {} - @types ||= [] - format = opts[:format] || :markdown - @types << SiteTextType.new(text_type, format, opts) - end - - def text_for_cache - @text_for_cache ||= DistributedCache.new("text_for_cache") - end - - def text_for(text_type, replacements=nil) - text = nil - text = text_for_cache[text_type] if replacements.blank? - text ||= uncached_text_for(text_type, replacements) - end - - def uncached_text_for(text_type, replacements=nil) - store_cache = replacements.blank? - - replacements ||= {} - replacements = {site_name: SiteSetting.title}.merge!(replacements) - replacements = SiteSetting.settings_hash.merge!(replacements) - - site_text = SiteText.select(:value).find_by(text_type: text_type) - - result = "" - if site_text.blank? - ct = find_text_type(text_type) - result = ct.default_text.dup if ct.present? - else - result = site_text.value.dup - end - - result.gsub!(/\%\{[^}]+\}/) do |m| - replacements[m[2..-2].to_sym] || m - end - - if store_cache - result.freeze - text_for_cache[text_type] = result - end - - result - end - - def find_or_new(text_type) - site_text = SiteText.find_by(text_type: text_type) - return site_text if site_text.present? - - site_text = SiteText.new - site_text.text_type = text_type - site_text - end - -end diff --git a/lib/system_message.rb b/lib/system_message.rb index 176298839cc..5e4a82df8dd 100644 --- a/lib/system_message.rb +++ b/lib/system_message.rb @@ -50,7 +50,7 @@ class SystemMessage site_name: SiteSetting.title, username: @recipient.username, user_preferences_url: "#{Discourse.base_url}/users/#{@recipient.username_lower}/preferences", - new_user_tips: SiteText.text_for(:usage_tips, base_url: Discourse.base_url), + new_user_tips: I18n.t('system_messages.usage_tips.text_body_template', base_url: Discourse.base_url), site_password: "", base_url: Discourse.base_url, } diff --git a/spec/components/discourse_i18n_spec.rb b/spec/components/discourse_i18n_spec.rb index dc6cd2293cd..967b71a1dc7 100644 --- a/spec/components/discourse_i18n_spec.rb +++ b/spec/components/discourse_i18n_spec.rb @@ -25,6 +25,21 @@ describe I18n::Backend::DiscourseI18n do expect(backend.translate(:en, 'wat', count: 3)).to eq("Hello 3") end + it 'can be searched by key or value' do + expect(backend.search(:en, 'fo')).to eq({'foo' => 'Foo in :en'}) + expect(backend.search(:en, 'foo')).to eq({'foo' => 'Foo in :en' }) + expect(backend.search(:en, 'Foo')).to eq({'foo' => 'Foo in :en' }) + expect(backend.search(:en, 'hello')).to eq({'wat' => 'Hello %{count}' }) + expect(backend.search(:en, 'items.one')).to eq({'items.one' => 'one item' }) + end + + it 'can return multiple results' do + results = backend.search(:en, 'item') + + expect(results['items.one']).to eq('one item') + expect(results['items.other']).to eq('%{count} items') + end + describe '#exists?' do it 'returns true when a key is given that exists' do expect(backend.exists?(:de, :bar)).to eq(true) @@ -67,12 +82,21 @@ describe I18n::Backend::DiscourseI18n do expect(I18n.translate('foo')).to eq('new value') end + it "can be searched" do + TranslationOverride.upsert!('en', 'wat', 'Overwritten value') + expect(I18n.search('wat', backend: backend)).to eq({'wat' => 'Overwritten value'}) + expect(I18n.search('Overwritten', backend: backend)).to eq({'wat' => 'Overwritten value'}) + expect(I18n.search('Hello', backend: backend)).to eq({}) + end + it 'supports disabling' do - TranslationOverride.upsert!('en', 'foo', 'meep') + orig_title = I18n.t('title') + TranslationOverride.upsert!('en', 'title', 'overridden title') I18n.overrides_disabled do - expect(I18n.translate('foo')).to eq('meep') + expect(I18n.translate('title')).to eq(orig_title) end + expect(I18n.translate('title')).to eq('overridden title') end it 'supports interpolation' do @@ -104,10 +128,12 @@ describe I18n::Backend::DiscourseI18n do it "returns client overrides" do TranslationOverride.upsert!('en', 'js.foo', 'bar') + TranslationOverride.upsert!('en', 'admin_js.beep', 'boop') json = ::JSON.parse(I18n.client_overrides_json) expect(json).to be_present expect(json['js.foo']).to eq('bar') + expect(json['admin_js.beep']).to eq('boop') end end end diff --git a/spec/controllers/admin/site_text_types_controller_spec.rb b/spec/controllers/admin/site_text_types_controller_spec.rb deleted file mode 100644 index d2431f60c8f..00000000000 --- a/spec/controllers/admin/site_text_types_controller_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'spec_helper' - -describe Admin::SiteTextTypesController do - - it "is a subclass of AdminController" do - expect(Admin::SiteTextTypesController < Admin::AdminController).to eq(true) - end - - context 'while logged in as an admin' do - before do - @user = log_in(:admin) - end - - context ' .index' do - it 'returns success' do - xhr :get, :index - expect(response).to be_success - end - - it 'returns JSON' do - xhr :get, :index - expect(::JSON.parse(response.body)).to be_present - end - end - end - -end diff --git a/spec/controllers/admin/site_texts_controller_spec.rb b/spec/controllers/admin/site_texts_controller_spec.rb index d6bf42d113b..aa7399b5006 100644 --- a/spec/controllers/admin/site_texts_controller_spec.rb +++ b/spec/controllers/admin/site_texts_controller_spec.rb @@ -11,17 +11,69 @@ describe Admin::SiteTextsController do @user = log_in(:admin) end - context '.show' do - let(:text_type) { SiteText.text_types.first.text_type } - - it 'returns success' do - xhr :get, :show, id: text_type + context '.index' do + it 'returns json' do + xhr :get, :index, q: 'title' expect(response).to be_success + expect(::JSON.parse(response.body)).to be_present + end + end + + context '.show' do + it 'returns a site text for a key that exists' do + xhr :get, :show, id: 'title' + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json).to be_present + + site_text = json['site_text'] + expect(site_text).to be_present + + expect(site_text['id']).to eq('title') + expect(site_text['value']).to eq(I18n.t(:title)) end - it 'returns JSON' do - xhr :get, :show, id: text_type - expect(::JSON.parse(response.body)).to be_present + it 'returns not found for missing keys' do + xhr :get, :show, id: 'made_up_no_key_exists' + expect(response).not_to be_success + end + end + + context '.update and .revert' do + it 'updates and reverts the key' do + orig_title = I18n.t(:title) + + xhr :put, :update, id: 'title', site_text: {value: 'hello'} + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json).to be_present + + site_text = json['site_text'] + expect(site_text).to be_present + + expect(site_text['id']).to eq('title') + expect(site_text['value']).to eq('hello') + + + # Revert + xhr :put, :revert, id: 'title' + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json).to be_present + + site_text = json['site_text'] + expect(site_text).to be_present + + expect(site_text['id']).to eq('title') + expect(site_text['value']).to eq(orig_title) + end + + it 'returns not found for missing keys' do + xhr :put, :update, id: 'made_up_no_key_exists', site_text: {value: 'hello'} + expect(response).not_to be_success end end end diff --git a/spec/fabricators/site_text_fabricator.rb b/spec/fabricators/site_text_fabricator.rb deleted file mode 100644 index 34023da8f63..00000000000 --- a/spec/fabricators/site_text_fabricator.rb +++ /dev/null @@ -1,14 +0,0 @@ -Fabricator(:site_text) do - text_type "great.poem" - value "%{flower} are red. %{food} are blue." -end - -Fabricator(:site_text_basic, from: :site_text) do - text_type "breaking.bad" - value "best show ever" -end - -Fabricator(:site_text_site_setting, from: :site_text) do - text_type "site.replacement" - value "%{title} is evil." -end diff --git a/spec/models/site_text_spec.rb b/spec/models/site_text_spec.rb deleted file mode 100644 index 2245683593d..00000000000 --- a/spec/models/site_text_spec.rb +++ /dev/null @@ -1,83 +0,0 @@ -require 'spec_helper' - -describe SiteText do - - it { is_expected.to validate_presence_of :value } - - - describe "#text_for" do - - it "returns an empty string for a missing text_type" do - expect(SiteText.text_for('something_random')).to eq("") - end - - it "returns the default value for a text` type with a default" do - expect(SiteText.text_for("usage_tips")).to be_present - end - - it "correctly expires and bypasses cache" do - SiteSetting.enable_sso = false - text = SiteText.create!(text_type: "got.sso", value: "got sso: %{enable_sso}") - expect(SiteText.text_for("got.sso")).to eq("got sso: false") - SiteText.text_for("got.sso").frozen? == true - - SiteSetting.enable_sso = true - wait_for do - SiteText.text_for("got.sso") == "got sso: true" - end - - text.value = "I gots sso: %{enable_sso}" - text.save! - - wait_for do - SiteText.text_for("got.sso") == "I gots sso: true" - end - - expect(SiteText.text_for("got.sso", enable_sso: "frog")).to eq("I gots sso: frog") - end - - context "without replacements" do - let!(:site_text) { Fabricate(:site_text_basic) } - - it "returns the simple string" do - expect(SiteText.text_for('breaking.bad')).to eq("best show ever") - end - - end - - context "with replacements" do - let!(:site_text) { Fabricate(:site_text) } - let(:replacements) { {flower: 'roses', food: 'grapes'} } - - it "returns the correct string with replacements" do - expect(SiteText.text_for('great.poem', replacements)).to eq("roses are red. grapes are blue.") - end - - it "doesn't mind extra keys in the replacements" do - expect(SiteText.text_for('great.poem', replacements.merge(extra: 'key'))).to eq("roses are red. grapes are blue.") - end - - it "ignores missing keys" do - expect(SiteText.text_for('great.poem', flower: 'roses')).to eq("roses are red. %{food} are blue.") - end - end - - - context "replacing site_settings" do - let!(:site_text) { Fabricate(:site_text_site_setting) } - - it "replaces site_settings by default" do - SiteSetting.title = "Evil Trout" - expect(SiteText.text_for('site.replacement')).to eq("Evil Trout is evil.") - end - - it "allows us to override the default site settings" do - SiteSetting.title = "Evil Trout" - expect(SiteText.text_for('site.replacement', title: 'Good Tuna')).to eq("Good Tuna is evil.") - end - - end - - end - -end diff --git a/test/javascripts/acceptance/admin-site-text-test.js.es6 b/test/javascripts/acceptance/admin-site-text-test.js.es6 new file mode 100644 index 00000000000..65c4fb1deee --- /dev/null +++ b/test/javascripts/acceptance/admin-site-text-test.js.es6 @@ -0,0 +1,41 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Admin - Site Texts", { loggedIn: true }); + +test("search for a key", () => { + visit("/admin/customize/site_texts"); + + fillIn('.site-text-search', 'Test'); + andThen(() => ok(exists('.site-text'))); +}); + +test("edit and revert a site text by key", () => { + visit("/admin/customize/site_texts/site.test"); + andThen(() => { + equal(find('.title h3').text(), 'site.test'); + ok(!exists('.save-messages .saved')); + ok(!exists('.save-messages .saved')); + ok(!exists('.revert-site-text')); + }); + + // Change the value + fillIn('.site-text-value', 'New Test Value'); + click(".save-changes"); + + andThen(() => { + ok(exists('.save-messages .saved')); + ok(exists('.revert-site-text')); + }); + + // Revert the changes + click('.revert-site-text'); + andThen(() => { + ok(exists('.bootbox.modal')); + }); + click('.bootbox.modal .btn-primary'); + + andThen(() => { + ok(!exists('.save-messages .saved')); + ok(!exists('.revert-site-text')); + }); +}); diff --git a/test/javascripts/acceptance/queued-posts-test.js.es6 b/test/javascripts/acceptance/queued-posts-test.js.es6 deleted file mode 100644 index c1a755503a1..00000000000 --- a/test/javascripts/acceptance/queued-posts-test.js.es6 +++ /dev/null @@ -1,75 +0,0 @@ -import { acceptance } from "helpers/qunit-helpers"; - -acceptance("Queued Posts", { loggedIn: true }); - -test("approve a post", () => { - visit("/queued-posts"); - - click('.queued-post:eq(0) button.approve'); - andThen(() => { - ok(!exists('.queued-post'), 'it removes the post'); - }); -}); - -test("reject a post", () => { - visit("/queued-posts"); - - click('.queued-post:eq(0) button.reject'); - andThen(() => { - ok(!exists('.queued-post'), 'it removes the post'); - }); -}); - -test("delete user", () => { - visit("/queued-posts"); - - click('.queued-post:eq(0) button.delete-user'); - andThen(() => { - ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog'); - }); - - click('.modal-footer a:eq(1)'); - andThen(() => { - ok(!exists('.bootbox.modal'), 'it dismisses the modal'); - ok(exists('.queued-post'), "it doesn't remove the post"); - }); - - click('.queued-post:eq(0) button.delete-user'); - click('.modal-footer a:eq(0)'); - andThen(() => { - ok(!exists('.bootbox.modal'), 'it dismisses the modal'); - ok(!exists('.queued-post'), "it removes the post"); - }); -}); - -test("edit a post - cancel", () => { - visit("/queued-posts"); - - click('.queued-post:eq(0) button.edit'); - andThen(() => { - equal(find('.queued-post:eq(0) textarea').val(), 'queued post text', 'it shows an editor'); - }); - - fillIn('.queued-post:eq(0) textarea', 'new post text'); - click('.queued-post:eq(0) button.cancel'); - andThen(() => { - ok(!exists('textarea'), 'it disables editing'); - equal(find('.queued-post:eq(0) .body p').text(), 'queued post text', 'it reverts the new text'); - }); -}); - -test("edit a post - confirm", () => { - visit("/queued-posts"); - - click('.queued-post:eq(0) button.edit'); - andThen(() => { - equal(find('.queued-post:eq(0) textarea').val(), 'queued post text', 'it shows an editor'); - }); - - fillIn('.queued-post:eq(0) textarea', 'new post text'); - click('.queued-post:eq(0) button.confirm'); - andThen(() => { - ok(!exists('textarea'), 'it disables editing'); - equal(find('.queued-post:eq(0) .body p').text(), 'new post text', 'it has the new text'); - }); -}); diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index 0b6f555294f..1c1e034cfbe 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -25,9 +25,7 @@ function response(code, obj) { return [code, {"Content-Type": "application/json"}, obj]; } -function success() { - return response({ success: true }); -} +const success = () => response({ success: true }); const _widgets = [ {id: 123, name: 'Trout Lure'}, @@ -50,9 +48,7 @@ const colors = [{id: 1, name: 'Red'}, {id: 2, name: 'Green'}, {id: 3, name: 'Yellow'}]; -function loggedIn() { - return !!Discourse.User.current(); -} +const loggedIn = () => !!Discourse.User.current(); export default function() { @@ -77,9 +73,9 @@ export default function() { } }); - this.get('/admin/plugins', () => { return response({ plugins: [] }); }); + this.get('/admin/plugins', () => response({ plugins: [] })); - this.get('/composer-messages', () => { return response([]); }); + this.get('/composer-messages', () => response([])); this.get("/latest.json", () => { const json = fixturesByUrl['/latest.json']; @@ -101,27 +97,19 @@ export default function() { return response(json); }); - this.put('/users/eviltrout', () => { - return response({ user: {} }); - }); + this.put('/users/eviltrout', () => response({ user: {} })); - this.get("/t/280.json", function() { - return response(fixturesByUrl['/t/280/1.json']); - }); + this.get("/t/280.json", () => response(fixturesByUrl['/t/280/1.json'])); - this.get("/t/28830.json", function() { - return response(fixturesByUrl['/t/28830/1.json']); - }); + this.get("/t/28830.json", () => response(fixturesByUrl['/t/28830/1.json'])); - this.get("/t/9.json", function() { - return response(fixturesByUrl['/t/9/1.json']); - }); + this.get("/t/9.json", () => response(fixturesByUrl['/t/9/1.json'])); - this.get("/t/id_for/:slug", function() { + this.get("/t/id_for/:slug", () => { return response({id: 280, slug: "internationalization-localization", url: "/t/internationalization-localization/280"}); }); - this.get("/404-body", function() { + this.get("/404-body", () => { return [200, {"Content-Type": "text/html"}, "
not found
"]; }); @@ -135,9 +123,7 @@ export default function() { return response({category}); }); - this.get('/draft.json', function() { - return response({}); - }); + this.get('/draft.json', () => response({})); this.put('/queued_posts/:queued_post_id', function(request) { return response({ queued_post: {id: request.params.queued_post_id } }); @@ -173,37 +159,25 @@ export default function() { return response({available: true}); }); - this.post('/users', function() { - return response({success: true}); - }); + this.post('/users', () => response({success: true})); - this.get('/login.html', function() { - return [200, {}, 'LOGIN PAGE']; - }); + this.get('/login.html', () => [200, {}, 'LOGIN PAGE']); this.delete('/posts/:post_id', success); this.put('/posts/:post_id/recover', success); - this.put('/posts/:post_id', (request) => { + this.put('/posts/:post_id', request => { const data = parsePostData(request.requestBody); data.post.id = request.params.post_id; data.post.version = 2; return response(200, data.post); }); - this.get('/t/403.json', () => { - return response(403, {}); - }); + this.get('/t/403.json', () => response(403, {})); + this.get('/t/404.json', () => response(404, "not found")); + this.get('/t/500.json', () => response(502, {})); - this.get('/t/404.json', () => { - return response(404, "not found"); - }); - - this.get('/t/500.json', () => { - return response(502, {}); - }); - - this.put('/t/:slug/:id', (request) => { + this.put('/t/:slug/:id', request => { const data = parsePostData(request.requestBody); return response(200, { basic_topic: {id: request.params.id, @@ -264,7 +238,6 @@ export default function() { return response({ cool_thing }); }); - this.get('/widgets', function(request) { let result = _widgets; @@ -286,8 +259,18 @@ export default function() { this.delete('/widgets/:widget_id', success); - this.post('/topics/timings', function() { - return response(200, {}); + this.post('/topics/timings', () => response(200, {})); + + const siteText = {id: 'site.test', value: 'Test McTest'}; + this.get('/admin/customize/site_texts', () => response(200, {site_texts: [siteText] })); + this.get('/admin/customize/site_texts/:key', () => response(200, {site_text: siteText })); + this.delete('/admin/customize/site_texts/:key', () => response(200, {site_text: siteText })); + + this.put('/admin/customize/site_texts/:key', request => { + const result = parsePostData(request.requestBody); + result.id = request.params.key; + result.can_revert = true; + return response(200, {site_text: result}); }); }); @@ -304,9 +287,6 @@ export default function() { throw error; }; - server.checkPassthrough = function(request) { - return request.requestHeaders['Discourse-Script']; - }; - + server.checkPassthrough = request => request.requestHeaders['Discourse-Script']; return server; }