mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-06-22 18:39:52 +02:00
Merge commit '23298060b1c72a240818a07d3b62dd3cd5ae1e08' into v3.x
This commit is contained in:
71
CHANGELOG.md
71
CHANGELOG.md
@@ -1,3 +1,74 @@
|
||||
#### v3.12.1 (2024-12-20)
|
||||
|
||||
##### Chores
|
||||
|
||||
* up harmony (18904bbb)
|
||||
* up persona (b4ec3a6a)
|
||||
* incrementing version number - v3.12.0 (052c195e)
|
||||
* update changelog for v3.12.0 (5395062d)
|
||||
* incrementing version number - v3.11.1 (0c0dd480)
|
||||
* incrementing version number - v3.11.0 (acf27e85)
|
||||
* incrementing version number - v3.10.3 (57d54224)
|
||||
* incrementing version number - v3.10.2 (2f15f464)
|
||||
* incrementing version number - v3.10.1 (cca3a644)
|
||||
* incrementing version number - v3.10.0 (b60a9b4e)
|
||||
* incrementing version number - v3.9.1 (f120c91c)
|
||||
* incrementing version number - v3.9.0 (4880f32d)
|
||||
* incrementing version number - v3.8.4 (4833f9a6)
|
||||
* incrementing version number - v3.8.3 (97ce2c44)
|
||||
* incrementing version number - v3.8.2 (72d91251)
|
||||
* incrementing version number - v3.8.1 (527326f7)
|
||||
* incrementing version number - v3.8.0 (e228a6eb)
|
||||
* incrementing version number - v3.7.5 (6882894d)
|
||||
* incrementing version number - v3.7.4 (6678744c)
|
||||
* incrementing version number - v3.7.3 (2d62b6f6)
|
||||
* incrementing version number - v3.7.2 (cc257e7e)
|
||||
* incrementing version number - v3.7.1 (712365a5)
|
||||
* incrementing version number - v3.7.0 (9a6153d7)
|
||||
* incrementing version number - v3.6.7 (86a17e38)
|
||||
* incrementing version number - v3.6.6 (6604bf37)
|
||||
* incrementing version number - v3.6.5 (6c653625)
|
||||
* incrementing version number - v3.6.4 (83d131b4)
|
||||
* incrementing version number - v3.6.3 (fc7d2bfd)
|
||||
* incrementing version number - v3.6.2 (0f577a57)
|
||||
* incrementing version number - v3.6.1 (f1a69468)
|
||||
* incrementing version number - v3.6.0 (4cdf85f8)
|
||||
* incrementing version number - v3.5.3 (ed0e8783)
|
||||
* incrementing version number - v3.5.2 (52fbb2da)
|
||||
* incrementing version number - v3.5.1 (4c543488)
|
||||
* incrementing version number - v3.5.0 (d06fb4f0)
|
||||
* incrementing version number - v3.4.3 (5c984250)
|
||||
* incrementing version number - v3.4.2 (3f0dac38)
|
||||
* incrementing version number - v3.4.1 (01e69574)
|
||||
* incrementing version number - v3.4.0 (fd9247c5)
|
||||
* incrementing version number - v3.3.9 (5805e770)
|
||||
* incrementing version number - v3.3.8 (a5603565)
|
||||
* incrementing version number - v3.3.7 (b26f1744)
|
||||
* incrementing version number - v3.3.6 (7fb38792)
|
||||
* incrementing version number - v3.3.4 (a67f84ea)
|
||||
* incrementing version number - v3.3.3 (f94d239b)
|
||||
* incrementing version number - v3.3.2 (ec9dac97)
|
||||
* incrementing version number - v3.3.1 (151cc68f)
|
||||
* incrementing version number - v3.3.0 (fc1ad70f)
|
||||
* incrementing version number - v3.2.3 (b06d3e63)
|
||||
* incrementing version number - v3.2.2 (758ecfcd)
|
||||
* incrementing version number - v3.2.1 (20145074)
|
||||
* incrementing version number - v3.2.0 (9ecac38e)
|
||||
* incrementing version number - v3.1.7 (0b4e81ab)
|
||||
* incrementing version number - v3.1.6 (b3a3b130)
|
||||
* incrementing version number - v3.1.5 (ec19343a)
|
||||
* incrementing version number - v3.1.4 (2452783c)
|
||||
* incrementing version number - v3.1.3 (3b4e9d3f)
|
||||
* incrementing version number - v3.1.2 (40fa3489)
|
||||
* incrementing version number - v3.1.1 (40250733)
|
||||
* incrementing version number - v3.1.0 (0cb386bd)
|
||||
* incrementing version number - v3.0.1 (26f6ea49)
|
||||
* incrementing version number - v3.0.0 (224e08cd)
|
||||
|
||||
##### Bug Fixes
|
||||
|
||||
* check install.values, it can be undefined (9bb8002a)
|
||||
|
||||
#### v3.12.0 (2024-12-18)
|
||||
|
||||
##### Chores
|
||||
|
||||
@@ -107,11 +107,14 @@
|
||||
"flags:actionOnReject": "rescind",
|
||||
"notificationType_upvote": "notification",
|
||||
"notificationType_new-topic": "notification",
|
||||
"notificationType_new-topic-with-tag": "notification",
|
||||
"notificationType_new-topic-in-category": "notification",
|
||||
"notificationType_new-reply": "notification",
|
||||
"notificationType_post-edit": "notification",
|
||||
"notificationType_follow": "notification",
|
||||
"notificationType_new-chat": "notification",
|
||||
"notificationType_new-group-chat": "notification",
|
||||
"notificationType_new-public-chat": "none",
|
||||
"notificationType_group-invite": "notification",
|
||||
"notificationType_group-leave": "notification",
|
||||
"notificationType_group-request-membership": "notification",
|
||||
|
||||
@@ -98,20 +98,20 @@
|
||||
"multiparty": "4.2.3",
|
||||
"nconf": "0.12.1",
|
||||
"nodebb-plugin-2factor": "7.5.7",
|
||||
"nodebb-plugin-composer-default": "10.2.43",
|
||||
"nodebb-plugin-composer-default": "10.2.44",
|
||||
"nodebb-plugin-dbsearch": "6.2.5",
|
||||
"nodebb-plugin-emoji": "5.1.15",
|
||||
"nodebb-plugin-emoji-android": "4.0.0",
|
||||
"nodebb-plugin-markdown": "12.2.8",
|
||||
"nodebb-plugin-markdown": "12.2.9",
|
||||
"nodebb-plugin-mentions": "4.4.5",
|
||||
"nodebb-plugin-ntfy": "1.7.7",
|
||||
"nodebb-plugin-spam-be-gone": "2.3.0",
|
||||
"nodebb-rewards-essentials": "1.0.0",
|
||||
"nodebb-theme-harmony": "1.2.92",
|
||||
"nodebb-theme-harmony": "1.2.95",
|
||||
"nodebb-theme-lavender": "7.1.17",
|
||||
"nodebb-theme-peace": "2.2.32",
|
||||
"nodebb-theme-persona": "13.3.61",
|
||||
"nodebb-widget-essentials": "7.0.31",
|
||||
"nodebb-theme-peace": "2.2.33",
|
||||
"nodebb-theme-persona": "13.3.63",
|
||||
"nodebb-widget-essentials": "7.0.32",
|
||||
"nodemailer": "6.9.16",
|
||||
"nprogress": "0.2.0",
|
||||
"passport": "0.7.0",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"post-sort-option": "Post sort option, %1",
|
||||
"topic-sort-option": "Topic sort option, %1",
|
||||
"user-avatar-for": "User avatar for %1",
|
||||
"profile-page-for": "Profile page for user %1",
|
||||
"user-watched-tags": "User watched tags",
|
||||
"delete-upload-button": "Delete upload button",
|
||||
"group-page-link-for": "Group page link for %1"
|
||||
|
||||
@@ -122,8 +122,6 @@ get:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
resizeImageWidth:
|
||||
type: number
|
||||
cookies:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -122,8 +122,6 @@ get:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
resizeImageWidth:
|
||||
type: number
|
||||
cookies:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -117,16 +117,21 @@ blockquote {
|
||||
.hover-visible {
|
||||
visibility: hidden;
|
||||
}
|
||||
.hover-opacity-75 {
|
||||
opacity: 0;
|
||||
&:focus { opacity: 0.75 }
|
||||
}
|
||||
.hover-opacity-100 {
|
||||
opacity: 0;
|
||||
&:focus {opacity: 1; }
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.hover-d-block {
|
||||
display: block!important;
|
||||
}
|
||||
.hover-d-flex {
|
||||
display: flex!important;
|
||||
}
|
||||
.hover-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
.hover-d-block { display: block!important; }
|
||||
.hover-d-flex { display: flex!important; }
|
||||
.hover-visible { visibility: visible; }
|
||||
.hover-opacity-100 { opacity: 1; }
|
||||
.hover-opacity-75 { opacity: 0.75; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ if (document.readyState === 'loading') {
|
||||
if (!isTouchDevice) {
|
||||
els = els || $('body');
|
||||
els.tooltip({
|
||||
selector: '.avatar.avatar-tooltip',
|
||||
selector: '.avatar-tooltip',
|
||||
placement: placement || 'top',
|
||||
container: '#content',
|
||||
animation: false,
|
||||
|
||||
@@ -6,14 +6,19 @@ define('forum/header/notifications', function () {
|
||||
notifications.prepareDOM = function () {
|
||||
const notifTrigger = $('[component="notifications"] [data-bs-toggle="dropdown"]');
|
||||
|
||||
notifTrigger.on('show.bs.dropdown', (ev) => {
|
||||
requireAndCall('loadNotifications', $(ev.target).parent().find('[component="notifications/list"]'));
|
||||
notifTrigger.on('show.bs.dropdown', async (ev) => {
|
||||
const notifications = await app.require('notifications');
|
||||
const triggerEl = $(ev.target);
|
||||
notifications.loadNotifications(triggerEl, triggerEl.parent().find('[component="notifications/list"]'));
|
||||
});
|
||||
|
||||
notifTrigger.each((index, el) => {
|
||||
const dropdownEl = $(el).parent().find('.dropdown-menu');
|
||||
const triggerEl = $(el);
|
||||
const dropdownEl = triggerEl.parent().find('.dropdown-menu');
|
||||
if (dropdownEl.hasClass('show')) {
|
||||
requireAndCall('loadNotifications', dropdownEl.find('[component="notifications/list"]'));
|
||||
app.require('notifications').then((notifications) => {
|
||||
notifications.loadNotifications(triggerEl, dropdownEl.find('[component="notifications/list"]'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,18 +29,14 @@ define('forum/header/notifications', function () {
|
||||
socket.on('event:notifications.updateCount', onUpdateCount);
|
||||
};
|
||||
|
||||
function onNewNotification(data) {
|
||||
requireAndCall('onNewNotification', data);
|
||||
async function onNewNotification(data) {
|
||||
const notifications = await app.require('notifications');
|
||||
notifications.onNewNotification(data);
|
||||
}
|
||||
|
||||
function onUpdateCount(data) {
|
||||
requireAndCall('updateNotifCount', data);
|
||||
}
|
||||
|
||||
function requireAndCall(method, param) {
|
||||
require(['notifications'], function (notifications) {
|
||||
notifications[method](param);
|
||||
});
|
||||
async function onUpdateCount(data) {
|
||||
const notifications = await app.require('notifications');
|
||||
notifications.updateNotifCount(data);
|
||||
}
|
||||
|
||||
return notifications;
|
||||
|
||||
@@ -29,7 +29,10 @@ define('forum/topic/fork', [
|
||||
|
||||
$('body').append(forkModal);
|
||||
|
||||
categorySelector.init(forkModal.find('[component="category-selector"]'), {
|
||||
const dropdownEl = forkModal.find('[component="category-selector"]');
|
||||
dropdownEl.addClass('dropup');
|
||||
|
||||
categorySelector.init(dropdownEl, {
|
||||
onSelect: function (category) {
|
||||
selectedCategory = category;
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ define('forum/topic/images', [], function () {
|
||||
}
|
||||
|
||||
if (!imageEl.parent().is('a')) {
|
||||
if (utils.isRelativeUrl(src) && suffixRegex.test(src) && imageEl.get(0).naturalWidth >= config.resizeImageWidth) {
|
||||
if (utils.isRelativeUrl(src) && suffixRegex.test(src)) {
|
||||
src = src.replace(suffixRegex, '$1');
|
||||
}
|
||||
const alt = imageEl.attr('alt') || '';
|
||||
|
||||
@@ -28,8 +28,10 @@ define('forum/topic/move', [
|
||||
if (Move.moveAll || (Move.tids && Move.tids.length > 1)) {
|
||||
modal.find('.card-header').translateText('[[topic:move-topics]]');
|
||||
}
|
||||
const dropdownEl = modal.find('[component="category-selector"]');
|
||||
dropdownEl.addClass('dropup');
|
||||
|
||||
categorySelector.init(modal.find('[component="category-selector"]'), {
|
||||
categorySelector.init(dropdownEl, {
|
||||
onSelect: onCategorySelected,
|
||||
privilege: 'moderate',
|
||||
});
|
||||
|
||||
@@ -214,9 +214,9 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
|
||||
function renderTopicImage(topicObj) {
|
||||
if (topicObj.thumb) {
|
||||
return '<img src="' + topicObj.thumb + '" class="img-circle user-img" title="' + topicObj.user.username + '" />';
|
||||
return '<img src="' + topicObj.thumb + '" class="img-circle user-img" title="' + topicObj.user.displayname + '" />';
|
||||
}
|
||||
return '<img component="user/picture" data-uid="' + topicObj.user.uid + '" src="' + topicObj.user.picture + '" class="user-img" title="' + topicObj.user.username + '" />';
|
||||
return '<img component="user/picture" data-uid="' + topicObj.user.uid + '" src="' + topicObj.user.picture + '" class="user-img" title="' + topicObj.user.displayname + '" />';
|
||||
}
|
||||
|
||||
function renderDigestAvatar(block) {
|
||||
@@ -300,7 +300,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
}
|
||||
classNames = classNames || '';
|
||||
const attributes = new Map([
|
||||
['title', userObj.username],
|
||||
['title', userObj.displayname],
|
||||
['data-uid', userObj.uid],
|
||||
['class', `avatar ${classNames}${rounded ? ' avatar-rounded' : ''}`],
|
||||
]);
|
||||
@@ -313,7 +313,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
let output = '';
|
||||
|
||||
if (userObj.picture) {
|
||||
output += `<img${attr2String(attributes)} alt="${userObj.username}" loading="lazy" component="${component || 'avatar/picture'}" src="${userObj.picture}" style="${styles.join(' ')}" onError="this.remove()" itemprop="image" />`;
|
||||
output += `<img${attr2String(attributes)} alt="${userObj.displayname}" loading="lazy" component="${component || 'avatar/picture'}" src="${userObj.picture}" style="${styles.join(' ')}" onError="this.remove()" itemprop="image" />`;
|
||||
}
|
||||
output += `<span${attr2String(attributes)} component="${component || 'avatar/icon'}" style="${styles.join(' ')} background-color: ${userObj['icon:bgColor']}">${userObj['icon:text']}</span>`;
|
||||
return output;
|
||||
@@ -383,7 +383,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
</li>`;
|
||||
});
|
||||
|
||||
return html;
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
function register() {
|
||||
|
||||
@@ -28,7 +28,7 @@ define('notifications', [
|
||||
});
|
||||
hooks.on('filter:notifications.load', _addTimeagoString);
|
||||
|
||||
Notifications.loadNotifications = function (notifList, callback) {
|
||||
Notifications.loadNotifications = function (triggerEl, notifList, callback) {
|
||||
callback = callback || function () {};
|
||||
socket.emit('notifications.get', null, function (err, data) {
|
||||
if (err) {
|
||||
@@ -47,7 +47,7 @@ define('notifications', [
|
||||
if (scrollToPostIndexIfOnPage(notifEl)) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
components.get('notifications/list').dropdown('toggle');
|
||||
triggerEl.dropdown('toggle');
|
||||
}
|
||||
|
||||
const unread = notifEl.hasClass('unread');
|
||||
|
||||
@@ -28,9 +28,9 @@ define('search', [
|
||||
const toggleVisibility = searchFields.hasClass('hidden');
|
||||
|
||||
if (toggleVisibility) {
|
||||
searchInput.off('blur').on('blur', function dismissSearch() {
|
||||
searchFields.off('focusout').on('focusout', function dismissSearch() {
|
||||
setTimeout(function () {
|
||||
if (!searchInput.is(':focus')) {
|
||||
if (!searchFields.find(':focus').length) {
|
||||
searchFields.addClass('hidden');
|
||||
searchButton.removeClass('hidden');
|
||||
}
|
||||
@@ -177,30 +177,33 @@ define('search', [
|
||||
doSearch();
|
||||
}, 500));
|
||||
|
||||
let mousedownOnResults = false;
|
||||
quickSearchResults.on('mousedown', '.quick-search-results > *', function () {
|
||||
$(window).one('mouseup', function () {
|
||||
quickSearchResults.addClass('hidden');
|
||||
});
|
||||
mousedownOnResults = true;
|
||||
});
|
||||
inputEl.on('blur', function () {
|
||||
|
||||
const inputParent = inputEl.parent();
|
||||
const resultParent = quickSearchResults.parent();
|
||||
inputParent.on('focusout', hideResults);
|
||||
resultParent.on('focusout', hideResults);
|
||||
function hideResults() {
|
||||
setTimeout(function () {
|
||||
if (!inputEl.is(':focus') && !mousedownOnResults && !quickSearchResults.hasClass('hidden')) {
|
||||
if (!inputParent.find(':focus').length && !resultParent.find(':focus').length && !quickSearchResults.hasClass('hidden')) {
|
||||
quickSearchResults.addClass('hidden');
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
let ajaxified = false;
|
||||
hooks.on('action:ajaxify.end', function () {
|
||||
if (!ajaxify.isCold()) {
|
||||
ajaxified = true;
|
||||
}
|
||||
quickSearchResults.addClass('hidden');
|
||||
});
|
||||
|
||||
inputEl.on('focus', function () {
|
||||
mousedownOnResults = false;
|
||||
const query = inputEl.val();
|
||||
oldValue = query;
|
||||
if (query && quickSearchResults.find('#quick-search-results').children().length) {
|
||||
|
||||
@@ -79,6 +79,39 @@
|
||||
imageDimensions = getBackgroundImageDimensions($el);
|
||||
}
|
||||
|
||||
$(window).on('keydown.dbg', (e) => {
|
||||
var pos = $el.css('background-position').match(/(-?\d+).*?\s(-?\d+)/) || [];
|
||||
var xPos = parseInt(pos[1]) || 0;
|
||||
var yPos = parseInt(pos[2]) || 0;
|
||||
// We must convert percentage back to pixels
|
||||
if (options.units == 'percent') {
|
||||
xPos = Math.round(xPos / -200 * imageDimensions.width);
|
||||
yPos = Math.round(yPos / -200 * imageDimensions.height);
|
||||
}
|
||||
|
||||
var x = 0, y = 0;
|
||||
if (e.which === 37) { // left
|
||||
x = -5
|
||||
} else if (e.which === 39) { // right
|
||||
x = 5
|
||||
} else if (e.which === 38) { // up
|
||||
y = -5
|
||||
} else if (e.which === 40) { // down
|
||||
y = +5
|
||||
}
|
||||
if (options.units === 'percent') {
|
||||
xPos = options.axis === 'y' ? xPos : limit(-imageDimensions.width/2, 0, xPos+x, options.bound);
|
||||
yPos = options.axis === 'x' ? yPos : limit(-imageDimensions.height/2, 0, yPos+y, options.bound);
|
||||
|
||||
// Convert pixels to percentage
|
||||
$el.css('background-position', xPos / imageDimensions.width * -200 + '% ' + yPos / imageDimensions.height * -200 + '%');
|
||||
} else {
|
||||
xPos = options.axis === 'y' ? xPos : limit($el.innerWidth()-imageDimensions.width, 0, xPos+x, options.bound);
|
||||
yPos = options.axis === 'x' ? yPos : limit($el.innerHeight()-imageDimensions.height, 0, yPos+y, options.bound);
|
||||
}
|
||||
return [37, 38, 39, 40].includes(e.which) ? false : undefined;
|
||||
});
|
||||
|
||||
$el.on('mousedown.dbg touchstart.dbg', function(e) {
|
||||
if (e.target !== $el[0]) {
|
||||
return;
|
||||
@@ -145,7 +178,7 @@
|
||||
Plugin.prototype.disable = function() {
|
||||
var $el = $(this.element);
|
||||
$el.off('mousedown.dbg touchstart.dbg');
|
||||
$window.off('mousemove.dbg touchmove.dbg mouseup.dbg touchend.dbg mouseleave.dbg');
|
||||
$window.off('mousemove.dbg touchmove.dbg mouseup.dbg touchend.dbg mouseleave.dbg keydown.dbg');
|
||||
}
|
||||
|
||||
$.fn.backgroundDraggable = function(options) {
|
||||
|
||||
@@ -370,10 +370,10 @@ postsAPI.getUpvoters = async function (caller, data) {
|
||||
upvotedUids = upvotedUids.slice(0, cutoff - 1);
|
||||
}
|
||||
|
||||
const usernames = await user.getUsernamesByUids(upvotedUids);
|
||||
const users = await user.getUsersFields(upvotedUids, ['username']);
|
||||
return {
|
||||
otherCount,
|
||||
usernames,
|
||||
usernames: users.map(user => user.displayname),
|
||||
cutoff,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -87,7 +87,8 @@ program
|
||||
.option('--log-level <level>', 'Default logging level to use', 'info')
|
||||
.option('--config <value>', 'Specify a config file', 'config.json')
|
||||
.option('-d, --dev', 'Development mode, including verbose logging', false)
|
||||
.option('-l, --log', 'Log subprocess output to console', false);
|
||||
.option('-l, --log', 'Log subprocess output to console', false)
|
||||
.option('-y, --unattended', 'Answer yes to any prompts, like plugin upgrades', false);
|
||||
|
||||
// provide a yargs object ourselves
|
||||
// otherwise yargs will consume `--help` or `help`
|
||||
@@ -294,6 +295,7 @@ program
|
||||
].join('\n')}`);
|
||||
})
|
||||
.action((scripts, options) => {
|
||||
options.unattended = program.opts().unattended;
|
||||
if (program.opts().dev) {
|
||||
process.env.NODE_ENV = 'development';
|
||||
global.env = 'development';
|
||||
@@ -308,7 +310,8 @@ program
|
||||
.alias('upgradePlugins')
|
||||
.description('Upgrade plugins')
|
||||
.action(() => {
|
||||
require('./upgrade-plugins').upgradePlugins((err) => {
|
||||
const { unattended } = program.opts();
|
||||
require('./upgrade-plugins').upgradePlugins(unattended, (err) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ async function checkPlugins() {
|
||||
return upgradable;
|
||||
}
|
||||
|
||||
async function upgradePlugins() {
|
||||
async function upgradePlugins(unattended = false) {
|
||||
try {
|
||||
const found = await checkPlugins();
|
||||
if (found && found.length) {
|
||||
@@ -132,16 +132,18 @@ async function upgradePlugins() {
|
||||
console.log(chalk.green('\nAll packages up-to-date!'));
|
||||
return;
|
||||
}
|
||||
let result = { upgrade: 'y' };
|
||||
if (!unattended) {
|
||||
prompt.message = '';
|
||||
prompt.delimiter = '';
|
||||
|
||||
prompt.message = '';
|
||||
prompt.delimiter = '';
|
||||
|
||||
prompt.start();
|
||||
const result = await prompt.get({
|
||||
name: 'upgrade',
|
||||
description: '\nProceed with upgrade (y|n)?',
|
||||
type: 'string',
|
||||
});
|
||||
prompt.start();
|
||||
result = await prompt.get({
|
||||
name: 'upgrade',
|
||||
description: '\nProceed with upgrade (y|n)?',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
|
||||
if (['y', 'Y', 'yes', 'YES'].includes(result.upgrade)) {
|
||||
console.log('\nUpgrading packages...');
|
||||
|
||||
@@ -24,9 +24,9 @@ const steps = {
|
||||
},
|
||||
plugins: {
|
||||
message: 'Checking installed plugins for updates...',
|
||||
handler: async function () {
|
||||
handler: async function (options) {
|
||||
await require('../database').init();
|
||||
await upgradePlugins();
|
||||
await upgradePlugins(options.unattended);
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
@@ -45,14 +45,14 @@ const steps = {
|
||||
},
|
||||
};
|
||||
|
||||
async function runSteps(tasks) {
|
||||
async function runSteps(tasks, options) {
|
||||
try {
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const step = steps[tasks[i]];
|
||||
if (step && step.message && step.handler) {
|
||||
process.stdout.write(`\n${chalk.bold(`${i + 1}. `)}${chalk.yellow(step.message)}`);
|
||||
/* eslint-disable-next-line */
|
||||
await step.handler();
|
||||
await step.handler(options);
|
||||
}
|
||||
}
|
||||
const message = 'NodeBB Upgrade Complete!';
|
||||
@@ -95,7 +95,7 @@ async function runUpgrade(upgrades, options) {
|
||||
options.plugins || options.schema || options.build) {
|
||||
tasks = tasks.filter(key => options[key]);
|
||||
}
|
||||
await runSteps(tasks);
|
||||
await runSteps(tasks, options);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ helpers.getCustomUserFields = async function (callerUID, userData) {
|
||||
if (f.type === 'input-link' && userValue) {
|
||||
f.linkValue = validator.escape(String(userValue.replace('http://', '').replace('https://', '')));
|
||||
}
|
||||
f['select-options'] = f['select-options'].split('\n').filter(Boolean).map(
|
||||
f['select-options'] = (f['select-options'] || '').split('\n').filter(Boolean).map(
|
||||
opt => ({
|
||||
value: opt,
|
||||
selected: Array.isArray(userValue) ?
|
||||
|
||||
@@ -113,8 +113,14 @@ const doUnsubscribe = async (payload) => {
|
||||
user.updateDigestSetting(payload.uid, 'off'),
|
||||
]);
|
||||
} else if (payload.template === 'notification') {
|
||||
const currentToNewSetting = {
|
||||
notificationemail: 'notification',
|
||||
email: 'none',
|
||||
};
|
||||
const current = await db.getObjectField(`user:${payload.uid}:settings`, `notificationType_${payload.type}`);
|
||||
await user.setSetting(payload.uid, `notificationType_${payload.type}`, (current === 'notificationemail' ? 'notification' : 'none'));
|
||||
if (currentToNewSetting.hasOwnProperty(current)) {
|
||||
await user.setSetting(payload.uid, `notificationType_${payload.type}`, currentToNewSetting[current]);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -78,7 +78,6 @@ apiController.loadConfig = async function (req) {
|
||||
enablePostHistory: meta.config.enablePostHistory === 1,
|
||||
timeagoCutoff: meta.config.timeagoCutoff !== '' ? Math.max(0, parseInt(meta.config.timeagoCutoff, 10)) : meta.config.timeagoCutoff,
|
||||
timeagoCodes: languages.timeagoCodes,
|
||||
resizeImageWidth: meta.config.resizeImageWidth,
|
||||
cookies: {
|
||||
enabled: meta.config.cookieConsentEnabled === 1,
|
||||
message: translator.escape(validator.escape(meta.config.cookieConsentMessage || '[[global:cookies.message]]')).replace(/\\/g, '\\\\'),
|
||||
@@ -91,7 +90,7 @@ apiController.loadConfig = async function (req) {
|
||||
},
|
||||
emailPrompt: meta.config.emailPrompt,
|
||||
useragent: {
|
||||
isSafari: req.useragent.isSafari,
|
||||
isSafari: req.useragent && req.useragent.isSafari,
|
||||
},
|
||||
fontawesome: {
|
||||
pro: fontawesome_pro,
|
||||
|
||||
@@ -49,7 +49,10 @@ async function registerAndLoginUser(req, res, userData) {
|
||||
|
||||
const uid = await user.create(userData);
|
||||
if (res.locals.processLogin) {
|
||||
await authenticationController.doLogin(req, uid);
|
||||
const hasLoginPrivilege = await privileges.global.can('local:login', uid);
|
||||
if (hasLoginPrivilege) {
|
||||
await authenticationController.doLogin(req, uid);
|
||||
}
|
||||
}
|
||||
|
||||
// Distinguish registrations through invites from direct ones
|
||||
|
||||
@@ -6,7 +6,7 @@ const validator = require('validator');
|
||||
const meta = require('../meta');
|
||||
const user = require('../user');
|
||||
const plugins = require('../plugins');
|
||||
const privileges = require('../privileges');
|
||||
const privilegesHelpers = require('../privileges/helpers');
|
||||
const helpers = require('./helpers');
|
||||
|
||||
const Controllers = module.exports;
|
||||
@@ -124,7 +124,8 @@ Controllers.login = async function (req, res) {
|
||||
data.title = '[[pages:login]]';
|
||||
data.allowPasswordReset = !meta.config['password:disableEdit'];
|
||||
|
||||
const hasLoginPrivilege = await privileges.global.canGroup('local:login', 'registered-users');
|
||||
const loginPrivileges = await privilegesHelpers.getGroupPrivileges(0, ['groups:local:login']);
|
||||
const hasLoginPrivilege = !!loginPrivileges.find(privilege => privilege.privileges['groups:local:login']);
|
||||
data.allowLocalLogin = hasLoginPrivilege || parseInt(req.query.local, 10) === 1;
|
||||
|
||||
if (!data.allowLocalLogin && !data.allowRegistration && data.alternate_logins && data.authentication.length === 1) {
|
||||
|
||||
@@ -174,11 +174,12 @@ module.exports = function (module) {
|
||||
}
|
||||
|
||||
const data = {};
|
||||
fields.forEach((field) => {
|
||||
fields = fields.map((field) => {
|
||||
field = helpers.fieldToString(field);
|
||||
if (field) {
|
||||
data[field] = 1;
|
||||
}
|
||||
return field;
|
||||
});
|
||||
|
||||
const item = await module.client.collection('objects').findOne({ _key: key }, { projection: data });
|
||||
@@ -194,14 +195,13 @@ module.exports = function (module) {
|
||||
if (!key || (Array.isArray(key) && !key.length) || !Array.isArray(fields) || !fields.length) {
|
||||
return;
|
||||
}
|
||||
fields = fields.filter(Boolean);
|
||||
fields = fields.map(helpers.fieldToString).filter(Boolean);
|
||||
if (!fields.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {};
|
||||
fields.forEach((field) => {
|
||||
field = helpers.fieldToString(field);
|
||||
data[field] = '';
|
||||
});
|
||||
if (Array.isArray(key)) {
|
||||
|
||||
@@ -23,7 +23,9 @@ helpers.fieldToString = function (field) {
|
||||
field = field.toString();
|
||||
}
|
||||
// if there is a '.' in the field name it inserts subdocument in mongo, replace '.'s with \uff0E
|
||||
return field.replace(/\./g, '\uff0E');
|
||||
// replace $ with \uff04 so we can use $ in document fields
|
||||
return field.replace(/\./g, '\uff0E')
|
||||
.replace(/\$/g, '\uFF04');
|
||||
};
|
||||
|
||||
helpers.serializeData = function (data) {
|
||||
|
||||
@@ -172,8 +172,11 @@ module.exports = function (module) {
|
||||
if (key === undefined || key === null || field === undefined || field === null) {
|
||||
return;
|
||||
}
|
||||
await module.client.hdel(key, field);
|
||||
cache.del(key);
|
||||
field = field.toString();
|
||||
if (field) {
|
||||
await module.client.hdel(key, field);
|
||||
cache.del(key);
|
||||
}
|
||||
};
|
||||
|
||||
module.deleteObjectFields = async function (key, fields) {
|
||||
|
||||
@@ -354,8 +354,11 @@ Emailer.sendViaFallback = async (data) => {
|
||||
data.text = data.plaintext;
|
||||
delete data.plaintext;
|
||||
|
||||
// NodeMailer uses a combined "from"
|
||||
data.from = `${data.from_name}<${data.from}>`;
|
||||
// use an address object https://nodemailer.com/message/addresses/
|
||||
data.from = {
|
||||
name: data.from_name,
|
||||
address: data.from,
|
||||
};
|
||||
delete data.from_name;
|
||||
await Emailer.fallbackTransport.sendMail(data);
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ image.size = async function (path) {
|
||||
};
|
||||
|
||||
image.stripEXIF = async function (path) {
|
||||
if (!meta.config.stripEXIFData || path.endsWith('.svg')) {
|
||||
if (!meta.config.stripEXIFData || path.endsWith('.gif') || path.endsWith('.svg')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -164,7 +164,10 @@ actions.buildCSS = async function buildCSS(data) {
|
||||
loadPaths: data.paths,
|
||||
};
|
||||
if (data.minify) {
|
||||
opts.silenceDeprecations = ['mixed-decls', 'color-functions'];
|
||||
opts.silenceDeprecations = [
|
||||
'legacy-js-api', 'mixed-decls', 'color-functions',
|
||||
'global-builtin', 'import',
|
||||
];
|
||||
}
|
||||
const scssOutput = await sass.compileStringAsync(data.source, opts);
|
||||
css = scssOutput.css.toString();
|
||||
|
||||
@@ -254,7 +254,7 @@ module.exports = function (middleware) {
|
||||
if (res.locals.isAPI) {
|
||||
req.params.userslug = lowercaseSlug;
|
||||
} else {
|
||||
const newPath = req.path.replace(new RegExp(`/${req.params.userslug}`), () => `/${lowercaseSlug}`);
|
||||
const newPath = req.path.replace(`/${req.params.userslug}`, () => `/${lowercaseSlug}`);
|
||||
return res.redirect(`${nconf.get('relative_path')}${newPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ _mounts.post = (app, name, middleware, controllers) => {
|
||||
middleware.registrationComplete,
|
||||
middleware.pluginHooks,
|
||||
];
|
||||
app.get(`/${name}/:pid`, middleware.busyCheck, middlewares, controllers.posts.redirectToPost);
|
||||
app.get(`/api/${name}/:pid`, middlewares, controllers.posts.redirectToPost);
|
||||
app.get(`/${name}/:pid`, middleware.busyCheck, middlewares, helpers.tryRoute(controllers.posts.redirectToPost));
|
||||
app.get(`/api/${name}/:pid`, middlewares, helpers.tryRoute(controllers.posts.redirectToPost));
|
||||
};
|
||||
|
||||
_mounts.tags = (app, name, middleware, controllers) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ const tx = require('../translator');
|
||||
module.exports = function (User) {
|
||||
User.updateProfile = async function (uid, data, extraFields) {
|
||||
let fields = [
|
||||
'username', 'email', 'fullname', 'website', 'location',
|
||||
'username', 'email', 'fullname',
|
||||
'groupTitle', 'birthday', 'signature', 'aboutme',
|
||||
...await db.getSortedSetRange('user-custom-fields', 0, -1),
|
||||
];
|
||||
|
||||
@@ -140,12 +140,12 @@
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">[[admin/dashboard:popular-searches]] <a href="{config.relative_path}/admin/dashboard/searches" class="text-xs">[[admin/dashboard:view-all]]</a></div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm text-sm search-list">
|
||||
<table class="table table-sm text-sm search-list w-100">
|
||||
<tbody>
|
||||
{{{ each popularSearches }}}
|
||||
<tr>
|
||||
<td>{popularSearches.value}</td>
|
||||
<td class="text-end" style="width: 1px;">{formattedNumber(popularSearches.score)}</td>
|
||||
<td class="w-100 text-truncate" style="max-width:1px;">{popularSearches.value}</td>
|
||||
<td class="w-0 text-end text-nowrap">{formattedNumber(popularSearches.score)}</td>
|
||||
</tr>
|
||||
{{{ end }}}
|
||||
</tbody>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<button id="clear-search-history" class="btn btn-sm btn-light"><i class="fa fa-trash text-danger"></i> [[admin/dashboard:clear-search-history]]</button>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm text-sm search-list">
|
||||
<table class="table table-sm text-sm search-list w-100">
|
||||
<thead>
|
||||
<th>[[admin/dashboard:search-term]]</th>
|
||||
<th class="text-end">[[admin/dashboard:search-count]]</th>
|
||||
@@ -30,8 +30,8 @@
|
||||
{{{ end }}}
|
||||
{{{ each searches }}}
|
||||
<tr>
|
||||
<td>{searches.value}</td>
|
||||
<td class="text-end" style="width: 1px;">{formattedNumber(searches.score)}</td>
|
||||
<td class="w-100 text-truncate" style="max-width:1px;">{searches.value}</td>
|
||||
<td class="w-0 text-end text-nowrap">{formattedNumber(searches.score)}</td>
|
||||
</tr>
|
||||
{{{ end }}}
|
||||
</tbody>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary btn-sm dropdown-toggle" id="action-dropdown" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" type="button" disabled="disabled">[[admin/manage/users:edit]] <span class="caret"></span></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm" role="menu">
|
||||
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm overflow-auto" role="menu" style="max-height:75vh;">
|
||||
|
||||
<li><h6 class="dropdown-header">[[admin/manage/users:email]]</h6></li>
|
||||
<li><a href="#" class="dropdown-item rounded-1 change-email" role="menuitem"><i class="text-secondary fa fa-fw fa-envelope text-start"></i> [[admin/manage/users:change-email]]</a></li>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<div class="tool-modal d-flex">
|
||||
|
||||
|
||||
<div class="card shadow">
|
||||
<h5 class="card-header">[[topic:thread-tools.merge-topics]]</h5>
|
||||
<div class="card-body">
|
||||
@@ -13,7 +11,7 @@
|
||||
<span class="input-group-text"><i class="fa fa-search"></i></span>
|
||||
</div>
|
||||
|
||||
<div class="quick-search-container dropdown-menu d-block p-2 hidden">
|
||||
<div class="quick-search-container dropdown-menu d-block p-2 hidden w-100">
|
||||
<div class="text-center loading-indicator"><i class="fa fa-spinner fa-spin"></i></div>
|
||||
<div class="quick-search-results-container"></div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{{ end }}}
|
||||
<div component="chat/recent/room" data-roomid="{./roomId}" data-full="1" class="rounded-1 {{{ if ./unread }}}unread{{{ end }}}">
|
||||
<div class="d-flex gap-1 justify-content-between">
|
||||
<div class="chat-room-btn position-relative d-flex flex-grow-1 gap-2 justify-content-start align-items-start btn btn-ghost btn-sm ff-sans text-start">
|
||||
<a href="#" class="chat-room-btn position-relative d-flex flex-grow-1 gap-2 justify-content-start align-items-start btn btn-ghost btn-sm ff-sans text-start">
|
||||
<div class="main-avatar">
|
||||
{{{ if ./users.length }}}
|
||||
{{{ if ./groupChat}}}
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
<!-- IMPORT partials/chats/room-teaser.tpl -->
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div>
|
||||
<button class="mark-read btn btn-ghost btn-sm d-flex align-items-center justify-content-center flex-grow-0 flex-shrink-0 p-1" style="width: 1.5rem; height: 1.5rem;">
|
||||
<i class="unread fa fa-2xs fa-circle text-primary {{{ if !./unread }}}hidden{{{ end }}}" aria-label="[[unread:mark-as-read]]"></i>
|
||||
|
||||
@@ -31,7 +31,6 @@ describe('Controllers', () => {
|
||||
let fooUid;
|
||||
let adminUid;
|
||||
let category;
|
||||
let testRoutes = [];
|
||||
|
||||
before(async () => {
|
||||
category = await categories.create({
|
||||
@@ -56,48 +55,6 @@ describe('Controllers', () => {
|
||||
tid = result.topicData.tid;
|
||||
|
||||
pid = result.postData.pid;
|
||||
|
||||
testRoutes = [
|
||||
{ it: 'should load /reset without code', url: '/reset' },
|
||||
{ it: 'should load /reset with invalid code', url: '/reset/123123' },
|
||||
{ it: 'should load /login', url: '/login' },
|
||||
{ it: 'should load /register', url: '/register' },
|
||||
{ it: 'should load /robots.txt', url: '/robots.txt' },
|
||||
{ it: 'should load /manifest.webmanifest', url: '/manifest.webmanifest' },
|
||||
{ it: 'should load /outgoing?url=<url>', url: '/outgoing?url=http://youtube.com' },
|
||||
{ it: 'should 404 on /outgoing with no url', url: '/outgoing', status: 404 },
|
||||
{ it: 'should 404 on /outgoing with javascript: protocol', url: '/outgoing?url=javascript:alert(1);', status: 404 },
|
||||
{ it: 'should 404 on /outgoing with invalid url', url: '/outgoing?url=derp', status: 404 },
|
||||
{ it: 'should load /sping', url: '/sping', body: 'healthy' },
|
||||
{ it: 'should load /ping', url: '/ping', body: '200' },
|
||||
{ it: 'should handle 404', url: '/arouteinthevoid', status: 404 },
|
||||
{ it: 'should load topic rss feed', url: `/topic/${tid}.rss` },
|
||||
{ it: 'should load category rss feed', url: `/category/${cid}.rss` },
|
||||
{ it: 'should load topics rss feed', url: `/topics.rss` },
|
||||
{ it: 'should load recent rss feed', url: `/recent.rss` },
|
||||
{ it: 'should load top rss feed', url: `/top.rss` },
|
||||
{ it: 'should load popular rss feed', url: `/popular.rss` },
|
||||
{ it: 'should load popular rss feed with term', url: `/popular/day.rss` },
|
||||
{ it: 'should load recent posts rss feed', url: `/recentposts.rss` },
|
||||
{ it: 'should load category recent posts rss feed', url: `/category/${cid}/recentposts.rss` },
|
||||
{ it: 'should load user topics rss feed', url: `/user/foo/topics.rss` },
|
||||
{ it: 'should load tag rss feed', url: `/tags/nodebb.rss` },
|
||||
{ it: 'should load client.css', url: `/assets/client.css` },
|
||||
{ it: 'should load admin.css', url: `/assets/admin.css` },
|
||||
{ it: 'should load sitemap.xml', url: `/sitemap.xml` },
|
||||
{ it: 'should load sitemap/pages.xml', url: `/sitemap/pages.xml` },
|
||||
{ it: 'should load sitemap/categories.xml', url: `/sitemap/categories.xml` },
|
||||
{ it: 'should load sitemap/topics.1.xml', url: `/sitemap/topics.1.xml` },
|
||||
{ it: 'should load theme screenshot', url: `/css/previews/nodebb-theme-harmony` },
|
||||
{ it: 'should load users page', url: `/users` },
|
||||
{ it: 'should load users page section', url: `/users?section=online` },
|
||||
{ it: 'should load groups page', url: `/groups` },
|
||||
{ it: 'should get recent posts', url: `/api/recent/posts/month` },
|
||||
{ it: 'should get post data', url: `/api/v3/posts/${pid}` },
|
||||
{ it: 'should get topic data', url: `/api/v3/topics/${tid}` },
|
||||
{ it: 'should get category data', url: `/api/v3/categories/${cid}` },
|
||||
{ it: 'should return osd data', url: `/osd.xml` },
|
||||
];
|
||||
});
|
||||
|
||||
it('should load /config with csrf_token', async () => {
|
||||
@@ -222,6 +179,48 @@ describe('Controllers', () => {
|
||||
|
||||
describe('routes that should 200/404 etc.', () => {
|
||||
const baseUrl = nconf.get('url');
|
||||
const testRoutes = [
|
||||
{ it: 'should load /reset without code', url: '/reset' },
|
||||
{ it: 'should load /reset with invalid code', url: '/reset/123123' },
|
||||
{ it: 'should load /login', url: '/login' },
|
||||
{ it: 'should load /register', url: '/register' },
|
||||
{ it: 'should load /robots.txt', url: '/robots.txt' },
|
||||
{ it: 'should load /manifest.webmanifest', url: '/manifest.webmanifest' },
|
||||
{ it: 'should load /outgoing?url=<url>', url: '/outgoing?url=http://youtube.com' },
|
||||
{ it: 'should 404 on /outgoing with no url', url: '/outgoing', status: 404 },
|
||||
{ it: 'should 404 on /outgoing with javascript: protocol', url: '/outgoing?url=javascript:alert(1);', status: 404 },
|
||||
{ it: 'should 404 on /outgoing with invalid url', url: '/outgoing?url=derp', status: 404 },
|
||||
{ it: 'should load /sping', url: '/sping', body: 'healthy' },
|
||||
{ it: 'should load /ping', url: '/ping', body: '200' },
|
||||
{ it: 'should handle 404', url: '/arouteinthevoid', status: 404 },
|
||||
{ it: 'should load topic rss feed', url: `/topic/1.rss` },
|
||||
{ it: 'should load category rss feed', url: `/category/1.rss` },
|
||||
{ it: 'should load topics rss feed', url: `/topics.rss` },
|
||||
{ it: 'should load recent rss feed', url: `/recent.rss` },
|
||||
{ it: 'should load top rss feed', url: `/top.rss` },
|
||||
{ it: 'should load popular rss feed', url: `/popular.rss` },
|
||||
{ it: 'should load popular rss feed with term', url: `/popular/day.rss` },
|
||||
{ it: 'should load recent posts rss feed', url: `/recentposts.rss` },
|
||||
{ it: 'should load category recent posts rss feed', url: `/category/1/recentposts.rss` },
|
||||
{ it: 'should load user topics rss feed', url: `/user/foo/topics.rss` },
|
||||
{ it: 'should load tag rss feed', url: `/tags/nodebb.rss` },
|
||||
{ it: 'should load client.css', url: `/assets/client.css` },
|
||||
{ it: 'should load admin.css', url: `/assets/admin.css` },
|
||||
{ it: 'should load sitemap.xml', url: `/sitemap.xml` },
|
||||
{ it: 'should load sitemap/pages.xml', url: `/sitemap/pages.xml` },
|
||||
{ it: 'should load sitemap/categories.xml', url: `/sitemap/categories.xml` },
|
||||
{ it: 'should load sitemap/topics.1.xml', url: `/sitemap/topics.1.xml` },
|
||||
{ it: 'should load theme screenshot', url: `/css/previews/nodebb-theme-harmony` },
|
||||
{ it: 'should load users page', url: `/users` },
|
||||
{ it: 'should load users page section', url: `/users?section=online` },
|
||||
{ it: 'should load groups page', url: `/groups` },
|
||||
{ it: 'should get recent posts', url: `/api/recent/posts/month` },
|
||||
{ it: 'should get post data', url: `/api/v3/posts/1` },
|
||||
{ it: 'should get topic data', url: `/api/v3/topics/1` },
|
||||
{ it: 'should get category data', url: `/api/v3/categories/1` },
|
||||
{ it: 'should return osd data', url: `/osd.xml` },
|
||||
{ it: 'should load service worker', url: '/service-worker.js' },
|
||||
];
|
||||
testRoutes.forEach((route) => {
|
||||
it(route.it, async () => {
|
||||
const { response, body } = await request.get(`${baseUrl}/${route.url}`);
|
||||
|
||||
@@ -155,18 +155,21 @@ describe('Hash methods', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for field names with "." in them when they are cached', (done) => {
|
||||
db.setObjectField('dotObject3', 'my.dot.field', 'foo2', (err) => {
|
||||
assert.ifError(err);
|
||||
db.getObject('dotObject3', (err, data) => {
|
||||
assert.ifError(err);
|
||||
db.getObjectField('dotObject3', 'my.dot.field', (err, value) => {
|
||||
assert.ifError(err);
|
||||
assert.equal(value, 'foo2');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
it('should work for field names with "." in them when they are cached', async () => {
|
||||
await db.setObjectField('dotObject3', 'my.dot.field', 'foo2');
|
||||
const data = await db.getObject('dotObject3');
|
||||
assert.strictEqual(data['my.dot.field'], 'foo2');
|
||||
const value = await db.getObjectField('dotObject3', 'my.dot.field');
|
||||
assert.equal(value, 'foo2');
|
||||
});
|
||||
|
||||
it('should work for fields that start with $', async () => {
|
||||
await db.setObjectField('dollarsign', '$someField', 'foo');
|
||||
assert.strictEqual(await db.getObjectField('dollarsign', '$someField'), 'foo');
|
||||
assert.strictEqual(await db.isObjectField('dollarsign', '$someField'), true);
|
||||
assert.strictEqual(await db.isObjectField('dollarsign', '$doesntexist'), false);
|
||||
await db.deleteObjectField('dollarsign', '$someField');
|
||||
assert.strictEqual(await db.isObjectField('dollarsign', '$someField'), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -521,6 +524,7 @@ describe('Hash methods', () => {
|
||||
|
||||
it('should not error if fields is empty array', async () => {
|
||||
await db.deleteObjectFields('someKey', []);
|
||||
await db.deleteObjectField('someKey', []);
|
||||
});
|
||||
|
||||
it('should not error if key is undefined', (done) => {
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('Post\'s', () => {
|
||||
|
||||
assert.deepStrictEqual(await db.sortedSetScores(`tid:${postResult.topicData.tid}:posters`, [oldUid, newUid]), [2, null]);
|
||||
|
||||
await posts.changeOwner([pid1, pid2], newUid);
|
||||
await socketPosts.changeOwner({ uid: globalModUid }, { pids: [pid1, pid2], toUid: newUid });
|
||||
|
||||
assert.deepStrictEqual(await db.sortedSetScores(`tid:${postResult.topicData.tid}:posters`, [oldUid, newUid]), [null, 2]);
|
||||
|
||||
@@ -1072,6 +1072,65 @@ describe('Post\'s', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('post editors', () => {
|
||||
it('should fail with invalid data', async () => {
|
||||
await assert.rejects(
|
||||
socketPosts.saveEditors({ uid: 0 }, {
|
||||
pid: 1,
|
||||
uids: [1],
|
||||
}),
|
||||
{ message: '[[error:no-privileges]]' },
|
||||
);
|
||||
await assert.rejects(
|
||||
socketPosts.saveEditors({ uid: 0 }, null),
|
||||
{ message: '[[error:invalid-data]]' },
|
||||
);
|
||||
await assert.rejects(
|
||||
socketPosts.saveEditors({ uid: 0 }, {
|
||||
pid: null,
|
||||
uids: [1],
|
||||
}),
|
||||
{ message: '[[error:invalid-data]]' },
|
||||
);
|
||||
await assert.rejects(
|
||||
socketPosts.saveEditors({ uid: 0 }, {
|
||||
pid: 1,
|
||||
uids: null,
|
||||
}),
|
||||
{ message: '[[error:invalid-data]]' },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
socketPosts.getEditors({ uid: 0 }, null),
|
||||
{ message: '[[error:invalid-data]]' },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
socketPosts.saveEditors({ uid: 0 }, { pid: null }),
|
||||
{ message: '[[error:invalid-data]]' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should add another user to post editors', async () => {
|
||||
const ownerUid = await user.create({ username: 'owner user' });
|
||||
const editorUid = await user.create({ username: 'editor user' });
|
||||
const topic = await topics.post({
|
||||
uid: ownerUid,
|
||||
cid,
|
||||
title: 'just a topic for multi editor testing',
|
||||
content: `Some text here for the OP`,
|
||||
});
|
||||
const { pid } = topic.postData;
|
||||
await socketPosts.saveEditors({ uid: ownerUid }, {
|
||||
pid: pid,
|
||||
uids: [editorUid],
|
||||
});
|
||||
|
||||
const userData = await socketPosts.getEditors({ uid: ownerUid }, { pid: pid });
|
||||
assert.strictEqual(userData[0].username, 'editor user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Topic Backlinks', () => {
|
||||
let tid1;
|
||||
before(async () => {
|
||||
|
||||
@@ -199,6 +199,26 @@ describe('socket.io', () => {
|
||||
assert(Array.isArray(users[0].groups));
|
||||
});
|
||||
|
||||
it('should error with invalid data set user reputation', async () => {
|
||||
await assert.rejects(
|
||||
socketAdmin.user.setReputation({ uid: adminUid }, null),
|
||||
{ message: '[[error:invalid-data]]' }
|
||||
);
|
||||
await assert.rejects(
|
||||
socketAdmin.user.setReputation({ uid: adminUid }, {}),
|
||||
{ message: '[[error:invalid-data]]' }
|
||||
);
|
||||
await assert.rejects(
|
||||
socketAdmin.user.setReputation({ uid: adminUid }, { uids: [], value: null }),
|
||||
{ message: '[[error:invalid-data]]' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should set user reputation', async () => {
|
||||
await socketAdmin.user.setReputation({ uid: adminUid }, { uids: [adminUid], value: 10 });
|
||||
assert.strictEqual(10, await db.sortedSetScore('users:reputation', adminUid));
|
||||
});
|
||||
|
||||
it('should reset lockouts', (done) => {
|
||||
socketAdmin.user.resetLockouts({ uid: adminUid }, [regularUid], (err) => {
|
||||
assert.ifError(err);
|
||||
|
||||
@@ -52,6 +52,17 @@ describe('helpers', () => {
|
||||
done();
|
||||
});
|
||||
|
||||
it('should return true if route is visible', (done) => {
|
||||
const flag = helpers.displayMenuItem({
|
||||
navigation: [{ route: '/recent' }],
|
||||
user: {
|
||||
privileges: {},
|
||||
},
|
||||
}, 0);
|
||||
assert(flag);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should stringify object', (done) => {
|
||||
const str = helpers.stringify({ a: 'herp < derp > and & quote "' });
|
||||
assert.equal(str, '{"a":"herp < derp > and & quote \\""}');
|
||||
@@ -64,7 +75,57 @@ describe('helpers', () => {
|
||||
done();
|
||||
});
|
||||
|
||||
it('should build category icon', (done) => {
|
||||
assert.strictEqual(
|
||||
helpers.buildCategoryIcon({
|
||||
bgColor: '#ff0000',
|
||||
color: '#00ff00',
|
||||
backgroundImage: '/assets/uploads/image.png',
|
||||
imageClass: 'auto',
|
||||
}, 16, 'rounded-circle'),
|
||||
'<span class="icon d-inline-flex justify-content-center align-items-center align-middle rounded-circle" style="background-color: #ff0000; border-color: #ff0000!important; color: #00ff00; background-image: url(/assets/uploads/image.png); background-size: auto; width:16; height: 16; font-size: 8px;"></span>'
|
||||
);
|
||||
assert.strictEqual(
|
||||
helpers.buildCategoryIcon({
|
||||
bgColor: '#ff0000',
|
||||
color: '#00ff00',
|
||||
backgroundImage: '/assets/uploads/image.png',
|
||||
imageClass: 'auto',
|
||||
icon: 'fa-book',
|
||||
}, 16, 'rounded-circle'),
|
||||
'<span class="icon d-inline-flex justify-content-center align-items-center align-middle rounded-circle" style="background-color: #ff0000; border-color: #ff0000!important; color: #00ff00; background-image: url(/assets/uploads/image.png); background-size: auto; width:16; height: 16; font-size: 8px;"><i class="fa fa-fw fa-book"></i></span>'
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should build category label', (done) => {
|
||||
assert.strictEqual(
|
||||
helpers.buildCategoryLabel({
|
||||
bgColor: '#ff0000',
|
||||
color: '#00ff00',
|
||||
backgroundImage: '/assets/uploads/image.png',
|
||||
imageClass: 'auto',
|
||||
name: 'Category 1',
|
||||
}, 'a', ''),
|
||||
`<a href="${nconf.get('relative_path')}/category/undefined" class="badge px-1 text-truncate text-decoration-none " style="color: #00ff00;background-color: #ff0000;border-color: #ff0000!important; max-width: 70vw;">\n\t\t\t\n\t\t\tCategory 1\n\t\t</a>`
|
||||
);
|
||||
assert.strictEqual(
|
||||
helpers.buildCategoryLabel({
|
||||
bgColor: '#ff0000',
|
||||
color: '#00ff00',
|
||||
backgroundImage: '/assets/uploads/image.png',
|
||||
imageClass: 'auto',
|
||||
name: 'Category 1',
|
||||
icon: 'fa-book',
|
||||
}, 'span', 'rounded-1'),
|
||||
`<span class="badge px-1 text-truncate text-decoration-none rounded-1" style="color: #00ff00;background-color: #ff0000;border-color: #ff0000!important; max-width: 70vw;">\n\t\t\t<i class="fa fa-fw fa-book"></i>\n\t\t\tCategory 1\n\t\t</span>`,
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should return empty string if category is falsy', (done) => {
|
||||
assert.equal(helpers.buildCategoryIcon(null), '');
|
||||
assert.equal(helpers.buildCategoryLabel(null), '');
|
||||
assert.equal(helpers.generateCategoryBackground(null), '');
|
||||
done();
|
||||
});
|
||||
@@ -169,16 +230,16 @@ describe('helpers', () => {
|
||||
});
|
||||
|
||||
it('should render thumb as topic image', (done) => {
|
||||
const topicObj = { thumb: '/uploads/1.png', user: { username: 'baris' } };
|
||||
const topicObj = { thumb: '/uploads/1.png', user: { username: 'baris', displayname: 'Baris Soner Usakli' } };
|
||||
const html = helpers.renderTopicImage(topicObj);
|
||||
assert.equal(html, `<img src="${topicObj.thumb}" class="img-circle user-img" title="${topicObj.user.username}" />`);
|
||||
assert.equal(html, `<img src="${topicObj.thumb}" class="img-circle user-img" title="${topicObj.user.displayname}" />`);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should render user picture as topic image', (done) => {
|
||||
const topicObj = { thumb: '', user: { uid: 1, username: 'baris', picture: '/uploads/2.png' } };
|
||||
const topicObj = { thumb: '', user: { uid: 1, username: 'baris', displayname: 'Baris Soner Usakli', picture: '/uploads/2.png' } };
|
||||
const html = helpers.renderTopicImage(topicObj);
|
||||
assert.equal(html, `<img component="user/picture" data-uid="${topicObj.user.uid}" src="${topicObj.user.picture}" class="user-img" title="${topicObj.user.username}" />`);
|
||||
assert.equal(html, `<img component="user/picture" data-uid="${topicObj.user.uid}" src="${topicObj.user.picture}" class="user-img" title="${topicObj.user.displayname}" />`);
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -251,4 +312,34 @@ describe('helpers', () => {
|
||||
assert.equal(html, '<i class="fa fa-fw fa-question-circle"></i><i class="fa fa-fw fa-question-circle"></i>');
|
||||
done();
|
||||
});
|
||||
|
||||
it('should generate replied to or wrote based on toPid', (done) => {
|
||||
const now = Date.now();
|
||||
const iso = new Date().toISOString();
|
||||
let post = { pid: 2, toPid: 1, timestamp: now, timestampISO: iso, parent: { displayname: 'baris' } };
|
||||
let str = helpers.generateWroteReplied(post, 1);
|
||||
assert.strictEqual(str, `[[topic:replied-to-user-ago, 1, ${nconf.get('relative_path')}/post/1, baris, ${nconf.get('relative_path')}/post/2, ${iso}]]`);
|
||||
|
||||
post = { pid: 2, toPid: 1, timestamp: now, timestampISO: iso, parent: { displayname: 'baris' } };
|
||||
str = helpers.generateWroteReplied(post, -1);
|
||||
assert.strictEqual(str, `[[topic:replied-to-user-on, 1, ${nconf.get('relative_path')}/post/1, baris, ${nconf.get('relative_path')}/post/2, ${iso}]]`);
|
||||
|
||||
post = { pid: 2, timestamp: now, timestampISO: iso, parent: { displayname: 'baris' } };
|
||||
str = helpers.generateWroteReplied(post, 1);
|
||||
assert.strictEqual(str, `[[topic:wrote-ago, ${nconf.get('relative_path')}/post/2, ${iso}]]`);
|
||||
|
||||
str = helpers.generateWroteReplied(post, -1);
|
||||
assert.strictEqual(str, `[[topic:wrote-on, ${nconf.get('relative_path')}/post/2, ${iso}]]`);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
it('should generate placeholder wave', (done) => {
|
||||
const items = [2, 'divider', 3];
|
||||
const str = helpers.generatePlaceholderWave(items);
|
||||
assert(str.includes('dropdown-divider'));
|
||||
assert(str.includes('col-2'));
|
||||
assert(str.includes('col-3'));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,36 @@ describe('Translator shim', () => {
|
||||
assert.strictEqual(t, 'secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateKeys', () => {
|
||||
it('should translate each key in array', async () => {
|
||||
const translated = await shim.translateKeys(['[[global:home]]', '[[global:search]]'], 'en-GB');
|
||||
assert.deepStrictEqual(translated, ['Home', 'Search']);
|
||||
});
|
||||
|
||||
it('should translate each key in array using a callback', (done) => {
|
||||
shim.translateKeys(['[[global:save]]', '[[global:close]]'], 'en-GB', (translated) => {
|
||||
assert.deepStrictEqual(translated, ['Save', 'Close']);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should load translations for language', (done) => {
|
||||
shim.load('en-GB', 'global', (translations) => {
|
||||
assert(translations);
|
||||
assert(translations['header.profile']);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get translations for language', (done) => {
|
||||
shim.getTranslations('en-GB', 'global', (translations) => {
|
||||
assert(translations);
|
||||
assert(translations['header.profile']);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('new Translator(language)', () => {
|
||||
|
||||
124
test/user/custom-fields.js
Normal file
124
test/user/custom-fields.js
Normal file
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
|
||||
const nconf = require('nconf');
|
||||
const assert = require('assert');
|
||||
const async = require('async');
|
||||
|
||||
const db = require('../mocks/databasemock');
|
||||
|
||||
const user = require('../../src/user');
|
||||
const groups = require('../../src/groups');
|
||||
|
||||
const request = require('../../src/request');
|
||||
const socketUser = require('../../src/socket.io/user');
|
||||
const adminUser = require('../../src/socket.io/admin/user');
|
||||
|
||||
|
||||
describe('custom user fields', () => {
|
||||
let adminUid;
|
||||
let lowRepUid;
|
||||
let highRepUid;
|
||||
before(async () => {
|
||||
adminUid = await user.create({ username: 'admin' });
|
||||
await groups.join('administrators', adminUid);
|
||||
lowRepUid = await user.create({ username: 'lowRepUser' });
|
||||
highRepUid = await user.create({ username: 'highRepUser' });
|
||||
await db.setObjectField(`user:${highRepUid}`, 'reputation', 10);
|
||||
await db.sortedSetAdd(`users:reputation`, 10, highRepUid);
|
||||
});
|
||||
|
||||
it('should create custom user fields', async () => {
|
||||
const fields = [
|
||||
{ key: 'website', icon: 'fa-solid fa-globe', name: 'Website', type: 'input-link', visibility: 'all', 'min:rep': 0 },
|
||||
{ key: 'location', icon: 'fa-solid fa-pin', name: 'Location', type: 'input-text', visibility: 'all', 'min:rep': 0 },
|
||||
{ key: 'favouriteDate', icon: '', name: 'Anniversary', type: 'input-date', visibility: 'all', 'min:rep': 0 },
|
||||
{ key: 'favouriteLanguages', icon: 'fa-solid fa-code', name: 'Favourite Languages', type: 'select-multi', visibility: 'all', 'min:rep': 0, 'select-options': 'C++\nC\nJavascript\nPython\nAssembly' },
|
||||
{ key: 'luckyNumber', icon: 'fa-solid fa-dice', name: 'Lucky Number', type: 'input-number', visibility: 'privileged', 'min:rep': 7 },
|
||||
{ key: 'soccerTeam', icon: 'fa-regular fa-futbol', name: 'Soccer Team', type: 'select', visibility: 'all', 'min:rep': 0, 'select-options': 'Barcelona\nLiverpool\nArsenal\nGalatasaray\n' },
|
||||
];
|
||||
await adminUser.saveCustomFields({ uid: adminUid }, fields);
|
||||
});
|
||||
|
||||
it('should fail to update a field if user does not have enough reputation', async () => {
|
||||
await assert.rejects(
|
||||
user.updateProfile(lowRepUid, {
|
||||
uid: lowRepUid,
|
||||
luckyNumber: 13,
|
||||
}),
|
||||
{ message: '[[error:not-enough-reputation-custom-field, 7, Lucky Number]]' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail with invalid field data', async () => {
|
||||
await assert.rejects(
|
||||
user.updateProfile(highRepUid, {
|
||||
uid: highRepUid,
|
||||
location: new Array(300).fill('a').join(''),
|
||||
}),
|
||||
{ message: '[[error:custom-user-field-value-too-long, Location]]' },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
user.updateProfile(highRepUid, {
|
||||
uid: highRepUid,
|
||||
luckyNumber: 'not-a-number',
|
||||
}),
|
||||
{ message: '[[error:custom-user-field-invalid-number, Lucky Number]]' },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
user.updateProfile(highRepUid, {
|
||||
uid: highRepUid,
|
||||
location: 'https://spam.com',
|
||||
}),
|
||||
{ message: '[[error:custom-user-field-invalid-text, Location]]' },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
user.updateProfile(highRepUid, {
|
||||
uid: highRepUid,
|
||||
favouriteDate: 'not-a-date',
|
||||
}),
|
||||
{ message: '[[error:custom-user-field-invalid-date, Anniversary]]' },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
user.updateProfile(highRepUid, {
|
||||
uid: highRepUid,
|
||||
website: 'not-a-url',
|
||||
}),
|
||||
{ message: '[[error:custom-user-field-invalid-link, Website]]' },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
user.updateProfile(highRepUid, {
|
||||
uid: highRepUid,
|
||||
soccerTeam: 'not-in-options',
|
||||
}),
|
||||
{ message: '[[error:custom-user-field-select-value-invalid, Soccer Team]]' },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
user.updateProfile(highRepUid, {
|
||||
uid: highRepUid,
|
||||
favouriteLanguages: '["not-in-options"]',
|
||||
}),
|
||||
{ message: '[[error:custom-user-field-select-value-invalid, Favourite Languages]]' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a users custom fields if they have enough reputation', async () => {
|
||||
await user.updateProfile(highRepUid, {
|
||||
uid: highRepUid,
|
||||
website: 'https://nodebb.org',
|
||||
location: 'Toronto',
|
||||
favouriteDate: '2014-05-01',
|
||||
favouriteLanguages: '["Javascript", "Python"]',
|
||||
luckyNumber: 13,
|
||||
soccerTeam: 'Galatasaray',
|
||||
});
|
||||
|
||||
const { body } = await request.get(`${nconf.get('url')}/api/user/highrepuser`);
|
||||
assert.strictEqual(body.website, 'https://nodebb.org');
|
||||
});
|
||||
});
|
||||
@@ -199,10 +199,9 @@ describe('Utility Methods', () => {
|
||||
utils.assertPasswordValidity('Yzsh31j!a', zxcvbn);
|
||||
});
|
||||
|
||||
// it('should generate UUID', () => {
|
||||
// TODO: add back when nodejs 18 is minimum
|
||||
// assert(validator.isUUID(utils.generateUUID()));
|
||||
// });
|
||||
it('should generate UUID', () => {
|
||||
assert(validator.isUUID(utils.generateUUID()));
|
||||
});
|
||||
|
||||
it('should shallow merge two objects', (done) => {
|
||||
const a = { foo: 1, cat1: 'ginger' };
|
||||
@@ -502,4 +501,77 @@ describe('Utility Methods', () => {
|
||||
assert.strictEqual(result.user1.uid, uid1);
|
||||
assert.strictEqual(result.user2.uid, uid2);
|
||||
});
|
||||
|
||||
describe('debounce/throttle', () => {
|
||||
it('should call function after x milliseconds once', (done) => {
|
||||
let count = 0;
|
||||
const now = Date.now();
|
||||
const fn = utils.debounce(() => {
|
||||
count += 1;
|
||||
assert.strictEqual(count, 1);
|
||||
assert(Date.now() - now > 50);
|
||||
}, 100);
|
||||
fn();
|
||||
fn();
|
||||
setTimeout(() => done(), 200);
|
||||
});
|
||||
|
||||
it('should call function first if immediate=true', (done) => {
|
||||
let count = 0;
|
||||
const now = Date.now();
|
||||
const fn = utils.debounce(() => {
|
||||
count += 1;
|
||||
assert.strictEqual(count, 1);
|
||||
assert(Date.now() - now < 50);
|
||||
}, 100, true);
|
||||
fn();
|
||||
fn();
|
||||
setTimeout(() => done(), 200);
|
||||
});
|
||||
|
||||
it('should call function after x milliseconds once', (done) => {
|
||||
let count = 0;
|
||||
const now = Date.now();
|
||||
const fn = utils.throttle(() => {
|
||||
count += 1;
|
||||
assert.strictEqual(count, 1);
|
||||
assert(Date.now() - now > 50);
|
||||
}, 100);
|
||||
fn();
|
||||
fn();
|
||||
setTimeout(() => done(), 200);
|
||||
});
|
||||
|
||||
it('should call function twice if immediate=true', (done) => {
|
||||
let count = 0;
|
||||
const fn = utils.throttle(() => {
|
||||
count += 1;
|
||||
}, 100, true);
|
||||
fn();
|
||||
fn();
|
||||
setTimeout(() => {
|
||||
assert.strictEqual(count, 2);
|
||||
done();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Translator', () => {
|
||||
const shim = require('../src/translator');
|
||||
|
||||
const { Translator } = shim;
|
||||
it('should translate in place', async () => {
|
||||
const translator = Translator.create('en-GB');
|
||||
const el = $(`<div><span id="search" title="[[global:search]]"></span><span id="text">[[global:home]]</span></div>`);
|
||||
await translator.translateInPlace(el.get(0));
|
||||
assert.strictEqual(el.find('#text').text(), 'Home');
|
||||
assert.strictEqual(el.find('#search').attr('title'), 'Search');
|
||||
});
|
||||
|
||||
it('should not error', (done) => {
|
||||
shim.flush();
|
||||
shim.flushNamespace();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user