FEATURE: Can override any translation via an admin interface
This commit is contained in:
parent
6354324f2f
commit
5e93140f85
|
@ -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
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
import CustomizationBase from 'admin/adapters/customization-base';
|
||||
export default CustomizationBase;
|
|
@ -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.$());
|
||||
}
|
||||
});
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -1 +0,0 @@
|
|||
export default Ember.ArrayController.extend();
|
|
@ -0,0 +1,3 @@
|
|||
Em.Handlebars.helper('preserve-newlines', str => {
|
||||
return new Handlebars.SafeString(Discourse.Utilities.escapeExpression(str).replace(/\n/g, "<br>"));
|
||||
});
|
|
@ -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;
|
|
@ -1,2 +0,0 @@
|
|||
import RestModel from 'discourse/models/rest';
|
||||
export default RestModel.extend();
|
|
@ -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'));
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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' });
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -1,5 +0,0 @@
|
|||
export default Discourse.Route.extend({
|
||||
model() {
|
||||
return this.store.findAll('site-text-type');
|
||||
}
|
||||
});
|
|
@ -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}}
|
||||
<div class='save-messages'>
|
||||
{{#if saved}}{{i18n 'saved'}}{{/if}}
|
||||
{{#if saved}}
|
||||
<div class='saved'>{{i18n 'saved'}}</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{{d-button label="admin.site_text.edit" class='edit' action="edit"}}
|
||||
<h3 class='site-text-id'>{{siteText.id}}</h3>
|
||||
<div class='site-text-value'>{{siteText.value}}</div>
|
||||
|
||||
<div class='clearfix'></div>
|
|
@ -1,17 +1,20 @@
|
|||
<h3>{{model.title}}</h3>
|
||||
<p class='description'>{{model.description}}</p>
|
||||
<div class='edit-site-text'>
|
||||
|
||||
{{#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}}
|
||||
<div class='title'>
|
||||
<h3>{{siteText.id}}</h3>
|
||||
</div>
|
||||
|
||||
{{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}}
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1 +1,19 @@
|
|||
<p>{{i18n 'admin.site_text.none'}}</p>
|
||||
<div class='search-area'>
|
||||
<p>{{i18n "admin.site_text.description"}}</p>
|
||||
|
||||
{{text-field value=q
|
||||
placeholderKey="admin.site_text.search"
|
||||
class="no-blur site-text-search"
|
||||
autofocus="true"
|
||||
keyUpAction="search"}}
|
||||
</div>
|
||||
|
||||
{{#conditional-loading-spinner condition=searching}}
|
||||
{{#unless siteTexts.findArgs.q}}
|
||||
<p><b>{{i18n "admin.site_text.recommended"}}</b></p>
|
||||
{{/unless}}
|
||||
|
||||
{{#each siteTexts as |siteText|}}
|
||||
{{site-text-summary siteText=siteText editAction="edit" term=q}}
|
||||
{{/each}}
|
||||
{{/conditional-loading-spinner}}
|
||||
|
|
|
@ -1,15 +1,3 @@
|
|||
<div class='row'>
|
||||
<div class='content-list span6'>
|
||||
<ul>
|
||||
{{#each c in model}}
|
||||
<li>
|
||||
{{#link-to 'adminSiteText.edit' c.text_type}}{{c.title}}{{/link-to}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='content-editor'>
|
||||
{{outlet}}
|
||||
</div>
|
||||
<div class='row site-texts'>
|
||||
{{outlet}}
|
||||
</div>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
});
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -16,7 +16,8 @@
|
|||
{{#if model.isPrivateMessage}}
|
||||
<span class="private-message-glyph">{{fa-icon "envelope"}}</span>
|
||||
{{/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}}
|
||||
<br>
|
||||
{{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
class Admin::SiteTextTypesController < Admin::AdminController
|
||||
|
||||
def index
|
||||
render_serialized(SiteText.text_types, SiteTextTypeSerializer, root: 'site_text_types')
|
||||
end
|
||||
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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={})
|
||||
|
|
|
@ -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
|
||||
#
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 <a href='/admin/site_settings'>Site Settings</a>."
|
||||
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 <head></head> 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 </body> 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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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'));
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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"}, "<div class='page-not-found'>not found</div>"];
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue