Merge branch 'main' into a-pnpm

# Conflicts:
#	.github/workflows/licenses.yml
#	.github/workflows/linting.yml
#	.github/workflows/tests.yml
#	app/assets/javascripts/discourse-common/package.json
#	app/assets/javascripts/discourse/lib/dialog-holder/yarn.lock
#	app/assets/javascripts/discourse/package.json
#	app/assets/javascripts/yarn.lock
#	lib/tasks/assets.rake
#	yarn.lock
This commit is contained in:
Jarek Radosz 2022-11-16 01:10:33 +01:00
commit 5b5a4e1fe2
No known key found for this signature in database
GPG Key ID: 62D0FBAE5BF9B953
518 changed files with 14971 additions and 4360 deletions

View File

@ -1,10 +1,8 @@
app/assets/javascripts/browser-update.js
app/assets/javascripts/locales/i18n.js
app/assets/javascripts/ember-addons/
app/assets/javascripts/discourse/lib/autosize.js
lib/javascripts/locale/
lib/javascripts/messageformat.js
lib/highlight_js/
lib/javascripts/messageformat-lookup.js
lib/pretty_text/
plugins/**/lib/javascripts/locale
public/

View File

@ -52,7 +52,7 @@ jobs:
- name: Get pnpm cache directory
id: node-cache-dir
run: echo "::set-output name=dir::$(pnpm store path --silent)"
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Node cache
uses: actions/cache@v3

View File

@ -49,7 +49,7 @@ jobs:
- name: Get pnpm cache directory
id: node-cache-dir
run: echo "::set-output name=dir::$(pnpm store path --silent)"
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Node cache
uses: actions/cache@v3

View File

@ -41,8 +41,6 @@ jobs:
target: plugins
- build_type: frontend
target: core # Handled by core_frontend_tests job (below)
- build_type: system
target: plugins # Enable once at least 1 plugin has system tests
steps:
- uses: actions/checkout@v3
@ -83,7 +81,7 @@ jobs:
- name: Get pnpm cache directory
id: node-cache-dir
run: echo "::set-output name=dir::$(pnpm store path --silent)"
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Node cache
uses: actions/cache@v3
@ -178,7 +176,7 @@ jobs:
- name: Plugin System Tests
if: matrix.build_type == 'system' && matrix.target == 'plugins'
run: bin/system_rspec plugins/*/spec/system
run: LOAD_PLUGINS=1 bin/system_rspec plugins/*/spec/system
- name: Upload failed system test screenshots
uses: actions/upload-artifact@v3
@ -223,7 +221,7 @@ jobs:
TESTEM_FIREFOX_PATH: ${{ (matrix.browser == 'Firefox Evergreen') && '/opt/firefox-evergreen/firefox' }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
fetch-depth: 1
@ -234,7 +232,7 @@ jobs:
- name: Get pnpm cache directory
id: node-cache-dir
run: echo "::set-output name=dir::$(pnpm store path --silent)"
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Node cache
uses: actions/cache@v3

2
.streerc Normal file
View File

@ -0,0 +1,2 @@
--print-width=100
--plugins=plugin/trailing_comma

View File

@ -89,7 +89,7 @@ GEM
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
byebug (11.1.3)
capybara (3.37.1)
capybara (3.38.0)
addressable
matrix
mini_mime (>= 0.1.3)
@ -145,17 +145,17 @@ GEM
sprockets (>= 3.3, < 4.1)
ember-source (2.18.2)
erubi (1.11.0)
excon (0.93.1)
excon (0.94.0)
execjs (2.8.1)
exifr (1.3.10)
fabrication (2.30.0)
faker (2.23.0)
i18n (>= 1.8.11, < 2)
fakeweb (1.3.0)
faraday (2.6.0)
faraday (2.7.0)
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-net_http (3.0.1)
faraday-net_http (3.0.2)
faraday-retry (2.0.0)
faraday (~> 2.0)
fast_blank (1.0.1)
@ -185,7 +185,7 @@ GEM
image_size (3.2.0)
in_threads (1.6.0)
jmespath (1.6.1)
jquery-rails (4.5.0)
jquery-rails (4.5.1)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
@ -239,7 +239,7 @@ GEM
mini_suffix (0.3.3)
ffi (~> 1.9)
minitest (5.16.3)
mocha (2.0.1)
mocha (2.0.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.6.0)
multi_json (1.15.0)
@ -307,7 +307,7 @@ GEM
openssl (> 2.0, < 3.1)
optimist (3.0.1)
parallel (1.22.1)
parallel_tests (3.13.0)
parallel_tests (4.0.0)
parallel
parser (3.1.2.1)
ast (~> 2.4.1)
@ -329,7 +329,7 @@ GEM
rack (2.2.4)
rack-mini-profiler (3.0.0)
rack (>= 1.2.0)
rack-protection (3.0.2)
rack-protection (3.0.3)
rack
rack-test (2.0.2)
rack (>= 1.3)
@ -371,7 +371,7 @@ GEM
rack (>= 1.4)
rexml (3.2.5)
rinku (2.0.6)
rotp (6.2.0)
rotp (6.2.1)
rqrcode (2.1.2)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
@ -407,7 +407,7 @@ GEM
json-schema (>= 2.2, < 4.0)
railties (>= 3.1, < 7.1)
rspec-core (>= 2.14)
rubocop (1.38.0)
rubocop (1.39.0)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.2.1)
@ -443,7 +443,7 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (4.5.0)
selenium-webdriver (4.6.1)
childprocess (>= 0.5, < 5.0)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
@ -507,7 +507,7 @@ GEM
xpath (3.2.0)
nokogiri (~> 1.8)
yaml-lint (0.0.10)
zeitwerk (2.6.4)
zeitwerk (2.6.6)
PLATFORMS
aarch64-linux

View File

@ -2,7 +2,7 @@ import discourseComputed, { observes } from "discourse-common/utils/decorators";
import Component from "@ember/component";
import I18n from "I18n";
import WatchedWord from "admin/models/watched-word";
import { equal } from "@ember/object/computed";
import { equal, not } from "@ember/object/computed";
import { isEmpty } from "@ember/utils";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
@ -16,7 +16,7 @@ export default Component.extend({
showMessage: false,
selectedTags: null,
isCaseSensitive: false,
submitDisabled: not("word"),
canReplace: equal("actionKey", "replace"),
canTag: equal("actionKey", "tag"),
canLink: equal("actionKey", "link"),

View File

@ -6,11 +6,13 @@ import discourseDebounce from "discourse-common/lib/debounce";
import { observes } from "discourse-common/utils/decorators";
import { clipboardCopy } from "discourse/lib/utilities";
import { inject as service } from "@ember/service";
import { or } from "@ember/object/computed";
export default Controller.extend({
dialog: service(),
loading: false,
filter: null,
showSearch: or("model.length", "filter"),
_debouncedShow() {
Permalink.findAll(this.filter).then((result) => {

View File

@ -1,17 +1,27 @@
import { action } from "@ember/object";
import Controller from "@ember/controller";
import discourseComputed from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service";
export default Controller.extend({
@discourseComputed
adminRoutes() {
router: service(),
get adminRoutes() {
return this.allAdminRoutes.filter((r) => this.routeExists(r.full_location));
},
get brokenAdminRoutes() {
return this.allAdminRoutes.filter(
(r) => !this.routeExists(r.full_location)
);
},
get allAdminRoutes() {
return this.model
.filter((p) => p?.enabled)
.map((p) => {
if (p.get("enabled")) {
return p.admin_route;
}
return p.admin_route;
})
.compact();
.filter(Boolean);
},
@action
@ -21,4 +31,13 @@ export default Controller.extend({
adminDetail.classList.toggle(state);
});
},
routeExists(routeName) {
try {
this.router.urlFor(routeName);
return true;
} catch (e) {
return false;
}
},
});

View File

@ -1,9 +1,22 @@
import Controller, { inject as controller } from "@ember/controller";
import discourseComputed from "discourse-common/utils/decorators";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Controller.extend(ModalFunctionality, {
adminBackupsLogs: controller(),
@discourseComputed
warningMessage() {
// this is never shown here, but we may want to show different
// messages in plugins
return "";
},
@discourseComputed
yesLabel() {
return "yes_value";
},
actions: {
startBackupWithUploads() {
this.send("closeModal");

View File

@ -43,6 +43,7 @@ export default Mixin.create({
validationMessage: null,
isSecret: oneWay("setting.secret"),
setting: null,
attributeBindings: ["setting.setting:data-setting"],
@discourseComputed("buffered.value", "setting.value")
dirty(bufferVal, settingVal) {
@ -136,6 +137,7 @@ export default Mixin.create({
"default_email_mailing_list_mode_frequency",
"default_email_previous_replies",
"default_email_in_reply_to",
"default_hide_profile_and_presence",
"default_other_new_topic_duration_minutes",
"default_other_auto_track_topics_after_msecs",
"default_other_notification_level_when_replying",

View File

@ -6,8 +6,8 @@
<ComboBox @content={{this.permalinkTypes}} @value={{this.permalinkType}} @onChange={{action (mut this.permalinkType)}} @class="permalink-type" />
<TextField @value={{this.permalinkTypeValue}} @disabled={{this.formSubmitted}} @placeholderKey={{this.permalinkTypePlaceholder}} @autocorrect="off" @autocapitalize="off" @keyDown={{action "submitFormOnEnter"}} />
<TextField @value={{this.permalinkTypeValue}} @disabled={{this.formSubmitted}} @class="permalink-destination" @placeholderKey={{this.permalinkTypePlaceholder}} @autocorrect="off" @autocapitalize="off" @keyDown={{action "submitFormOnEnter"}} />
<DButton @action={{action "onSubmit"}} @disabled={{this.formSubmitted}} @label="admin.permalink.form.add" />
<DButton @action={{action "onSubmit"}} @disabled={{this.formSubmitted}} @class="permalink-add" @label="admin.permalink.form.add" />
</div>
</div>

View File

@ -35,7 +35,7 @@
</label>
</div>
<DButton @type="submit" @class="btn btn-primary" @action={{action "submit"}} @disabled={{this.formSubmitted}} @label="admin.watched_words.form.add" />
<DButton @type="submit" @class="btn btn-primary" @action={{action "submit"}} @disabled={{this.submitDisabled}} @label="admin.watched_words.form.add" />
{{#if this.showMessage}}
<span class="success-message">{{this.message}}</span>

View File

@ -1,5 +1,8 @@
<DModalBody @title="admin.backups.operations.backup.confirm">
<DButton @class="btn-primary backup-with-uploads" @action={{action "startBackupWithUploads"}} @label="yes_value" />
{{#if this.warningMessage}}
<div class="alert alert-warning">{{html-safe this.warningMessage}}</div>
{{/if}}
<DButton @class="btn-primary backup-with-uploads" @action={{action "startBackupWithUploads"}} @label={{this.yesLabel}} />
<DButton @class="backup-no-uploads" @action={{action "startBackupWithoutUploads"}} @label="admin.backups.operations.backup.without_uploads" />
<DButton @class="btn-default" @action={{action "cancel"}} @label="no_value" />
</DModalBody>

View File

@ -6,51 +6,58 @@
<PermalinkForm @action={{action "recordAdded"}} />
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.model.length}}
<div class="permalink-search">
<TextField @value={{this.filter}} @class="url-input" @placeholderKey="admin.permalink.form.filter" @autocorrect="off" @autocapitalize="off" />
</div>
<table class="admin-logs-table permalinks grid">
<thead class="heading-container">
<th class="col heading first url">{{i18n "admin.permalink.url"}}</th>
<th class="col heading destination">{{i18n "admin.permalink.destination"}}</th>
<th class="col heading actions"></th>
</thead>
<tbody>
{{#each this.model as |pl|}}
<tr class="admin-list-item">
<td class="col first url">
<FlatButton @title="admin.permalink.copy_to_clipboard" @icon="far-clipboard" @action={{action "copyUrl" pl}} />
<span id="admin-permalink-{{pl.id}}" title={{pl.url}}>{{pl.url}}</span>
</td>
<td class="col destination">
{{#if pl.topic_id}}
<a href={{pl.topic_url}}>{{pl.topic_title}}</a>
{{/if}}
{{#if pl.post_id}}
<a href={{pl.post_url}}>{{pl.post_topic_title}} #{{pl.post_number}}</a>
{{/if}}
{{#if pl.category_id}}
{{category-link pl.category}}
{{/if}}
{{#if pl.tag_id}}
<a href={{pl.tag_url}}>{{pl.tag_name}}</a>
{{/if}}
{{#if pl.external_url}}
{{#if pl.linkIsExternal}}
{{d-icon "external-link-alt"}}
<div class="permalink-search">
<TextField @value={{this.filter}} @class="url-input" @placeholderKey="admin.permalink.form.filter" @autocorrect="off" @autocapitalize="off" />
</div>
<div class="permalink-results">
{{#if this.model.length}}
<table class="admin-logs-table permalinks grid">
<thead class="heading-container">
<th class="col heading first url">{{i18n "admin.permalink.url"}}</th>
<th class="col heading destination">{{i18n "admin.permalink.destination"}}</th>
<th class="col heading actions"></th>
</thead>
<tbody>
{{#each this.model as |pl|}}
<tr class="admin-list-item">
<td class="col first url">
<FlatButton @title="admin.permalink.copy_to_clipboard" @icon="far-clipboard" @action={{action "copyUrl" pl}} />
<span id="admin-permalink-{{pl.id}}" title={{pl.url}}>{{pl.url}}</span>
</td>
<td class="col destination">
{{#if pl.topic_id}}
<a href={{pl.topic_url}}>{{pl.topic_title}}</a>
{{/if}}
<a href={{pl.external_url}}>{{pl.external_url}}</a>
{{/if}}
</td>
<td class="col action" style="text-align: right;">
<DButton @action={{action "destroy"}} @actionParam={{pl}} @icon="far-trash-alt" @class="btn-danger" />
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
{{i18n "search.no_results"}}
{{/if}}
{{#if pl.post_id}}
<a href={{pl.post_url}}>{{pl.post_topic_title}} #{{pl.post_number}}</a>
{{/if}}
{{#if pl.category_id}}
{{category-link pl.category}}
{{/if}}
{{#if pl.tag_id}}
<a href={{pl.tag_url}}>{{pl.tag_name}}</a>
{{/if}}
{{#if pl.external_url}}
{{#if pl.linkIsExternal}}
{{d-icon "external-link-alt"}}
{{/if}}
<a href={{pl.external_url}}>{{pl.external_url}}</a>
{{/if}}
</td>
<td class="col action" style="text-align: right;">
<DButton @action={{action "destroy"}} @actionParam={{pl}} @icon="far-trash-alt" @class="btn-danger" />
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
{{#if this.filter}}
<p class="permalink-results__no-result">{{i18n "search.no_results"}}</p>
{{else}}
<p class="permalink-results__no-permalinks">{{i18n "admin.permalink.no_permalinks"}}</p>
{{/if}}
{{/if}}
</div>
</ConditionalLoadingSpinner>

View File

@ -13,7 +13,7 @@
</thead>
<tbody>
{{#each this.model as |plugin|}}
<tr>
<tr data-plugin-name={{plugin.name}}>
<td>
{{#if plugin.is_official}}
{{d-icon "check-circle"

View File

@ -19,5 +19,11 @@
</div>
<div class="admin-detail pull-left mobile-closed">
{{#each this.brokenAdminRoutes as |route|}}
<div class="alert alert-error">
{{i18n "admin.plugins.broken_route" name=(i18n route.label)}}
</div>
{{/each}}
{{outlet}}
</div>

View File

@ -5,6 +5,7 @@
"paths": {
"admin/*": ["./addon/*"],
"discourse/*": ["../discourse/app/*"],
"discourse/tests/*": ["../discourse/tests/*"],
"discourse-common/*": ["../discourse-common/addon/*"],
"pretty-text/*": ["../pretty-text/addon/*"],
}

View File

@ -18,7 +18,7 @@
"ember-auto-import": "^2.4.3",
"ember-cli-babel": "^7.26.10",
"ember-cli-htmlbars": "^6.1.1",
"webpack": "^5.74.0",
"webpack": "^5.75.0",
"xss": "^1.0.14"
},
"devDependencies": {

View File

@ -9,10 +9,18 @@ import { isTesting } from "discourse-common/config/environment";
export default function () {
if (isTesting()) {
const lastArgument = arguments[arguments.length - 1];
const hasImmediateArgument = typeof lastArgument === "boolean";
let args = [].slice.call(arguments, 0, hasImmediateArgument ? -2 : -1);
// Replace the time argument with 10ms
let args = [].slice.call(arguments, 0, -1);
args.push(10);
if (hasImmediateArgument) {
args.push(lastArgument);
}
return debounce.apply(undefined, args);
} else {
return debounce(...arguments);

View File

@ -88,7 +88,7 @@ export function readOnly(target, name, desc) {
};
}
export function debounce(delay) {
export function debounce(delay, immediate = false) {
return function (target, name, descriptor) {
return {
enumerable: descriptor.enumerable,
@ -97,7 +97,13 @@ export function debounce(delay) {
initializer() {
const originalFunction = descriptor.value;
const debounced = function (...args) {
return discourseDebounce(this, originalFunction, ...args, delay);
return discourseDebounce(
this,
originalFunction,
...args,
delay,
immediate
);
};
return debounced;

View File

@ -27,7 +27,7 @@
"ember-resolver": "^8.0.3",
"handlebars": "^4.7.0",
"truth-helpers": "workspace:*",
"webpack": "^5.74.0"
"webpack": "^5.75.0"
},
"devDependencies": {
"@babel/core": "^7.19.6",

View File

@ -19,7 +19,7 @@
"ember-cli-babel": "^7.26.10",
"ember-cli-htmlbars": "^6.1.1",
"handlebars": "^4.7.6",
"webpack": "^5.74.0"
"webpack": "^5.75.0"
},
"devDependencies": {
"@babel/core": "^7.19.6",

View File

@ -114,18 +114,22 @@ module.exports = {
directoryName,
"test/javascripts"
);
const configDirectory = path.resolve(root, directoryName, "config");
const hasJs = fs.existsSync(jsDirectory);
const hasAdminJs = fs.existsSync(adminJsDirectory);
const hasTests = fs.existsSync(testDirectory);
const hasConfig = fs.existsSync(configDirectory);
return {
pluginName,
directoryName,
jsDirectory,
adminJsDirectory,
testDirectory,
configDirectory,
hasJs,
hasAdminJs,
hasTests,
hasConfig,
};
});
},

View File

@ -5,6 +5,7 @@
"paths": {
"discourse-widget-hbs/*": ["./addon/*"],
"discourse/*": ["../discourse/app/*"],
"discourse/tests/*": ["../discourse/tests/*"],
"discourse-common/*": ["../discourse-common/addon/*"]
}
},

View File

@ -19,7 +19,7 @@
"ember-cli-babel": "^7.26.10",
"ember-cli-htmlbars": "^6.1.1",
"handlebars": "^4.7.6",
"webpack": "^5.74.0"
"webpack": "^5.75.0"
},
"devDependencies": {
"@babel/core": "^7.19.6",

View File

@ -51,7 +51,7 @@ export default Component.extend({
this.set("actions", connectorClass.actions);
for (const [name, action] of Object.entries(this.actions)) {
this.set(name, action);
this.set(name, action.bind(this));
}
const merged = buildArgsWithDeprecations(args, deprecatedArgs);

View File

@ -42,10 +42,12 @@ export default Component.extend({
}
},
@discourseComputed("backupEnabled", "secondFactorMethod")
showToggleMethodLink(backupEnabled, secondFactorMethod) {
@discourseComputed("backupEnabled", "totpEnabled", "secondFactorMethod")
showToggleMethodLink(backupEnabled, totpEnabled, secondFactorMethod) {
return (
backupEnabled && secondFactorMethod !== SECOND_FACTOR_METHODS.SECURITY_KEY
backupEnabled &&
totpEnabled &&
secondFactorMethod !== SECOND_FACTOR_METHODS.SECURITY_KEY
);
},

View File

@ -1,5 +1,5 @@
{{#if this.shouldDisplay}}
<div class="sidebar-section-link-wrapper">
<div class="sidebar-section-link-wrapper" {{did-insert this.didInsert this.args}}>
{{#if @href}}
<a href={{@href}} rel="noopener noreferrer" target="_blank" class={{this.classNames}} title={{@title}}>
<Sidebar::SectionLinkPrefix

View File

@ -7,6 +7,12 @@ export default class SectionLink extends Component {
}
}
didInsert(_element, [args]) {
if (args.didInsert) {
args.didInsert();
}
}
get shouldDisplay() {
if (this.args.shouldDisplay === undefined) {
return true;

View File

@ -43,6 +43,7 @@
@hoverAction={{link.hoverAction}}
@hoverTitle={{link.hoverTitle}}
@currentWhen={{link.currentWhen}}
@didInsert={{link.didInsert}}
@willDestroy={{link.willDestroy}}
@content={{link.text}} />
{{/each}}

View File

@ -238,7 +238,7 @@ const SiteHeaderComponent = MountWidget.extend(
this.currentUser.on("status-changed", this, "queueRerender");
}
if (!this.siteSettings.enable_onboarding_popups) {
if (!this.siteSettings.enable_user_tips) {
if (
this.currentUser &&
!this.get("currentUser.read_first_notification")

View File

@ -1,7 +1,8 @@
import { alias, and, or } from "@ember/object/computed";
import { alias, or } from "@ember/object/computed";
import { computed } from "@ember/object";
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
import { NotificationLevels } from "discourse/lib/notification-levels";
import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
import { getTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown";
@ -46,6 +47,11 @@ export default Component.extend({
return !isPM || this.canSendPms;
},
@discourseComputed("topic.details.notification_level")
showNotificationUserTip(notificationLevel) {
return notificationLevel >= NotificationLevels.TRACKING;
},
canSendPms: alias("currentUser.can_send_private_messages"),
canInviteTo: alias("topic.details.can_invite_to"),
@ -54,8 +60,6 @@ export default Component.extend({
inviteDisabled: or("topic.archived", "topic.closed", "topic.deleted"),
showEditOnFooter: and("topic.isPrivateMessage", "site.can_tag_pms"),
@discourseComputed("topic.message_archived")
archiveIcon: (archived) => (archived ? "envelope" : "folder"),

View File

@ -19,7 +19,7 @@
</LinkTo>
</li>
{{#if this.siteSettings.enable_mentions}}
{{#if @siteSettings.enable_mentions}}
<li>
<LinkTo @route="userNotifications.mentions">
{{d-icon "at"}}

View File

@ -0,0 +1 @@
<span {{did-insert this.showUserTip}}></span>

View File

@ -0,0 +1,35 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
import { hideUserTip } from "discourse/lib/user-tips";
import I18n from "I18n";
export default class UserTip extends Component {
@service currentUser;
@action
showUserTip(element) {
if (!this.currentUser) {
return;
}
const { id, selector, content, placement } = this.args;
this.currentUser.showUserTip({
id,
titleText: I18n.t(`user_tips.${id}.title`),
contentText: content || I18n.t(`user_tips.${id}.content`),
reference: selector
? element.parentElement.querySelector(selector) || element.parentElement
: element,
appendTo: element.parentElement,
placement: placement || "top",
});
}
willDestroy() {
hideUserTip(this.args.id);
}
}

View File

@ -223,21 +223,30 @@ export default Controller.extend(ModalFunctionality, {
this.clearFlash();
if (
(result.security_key_enabled || result.totp_enabled) &&
(result.security_key_enabled ||
result.totp_enabled ||
result.backup_enabled) &&
!this.secondFactorRequired
) {
let secondFactorMethod;
if (result.security_key_enabled) {
secondFactorMethod = SECOND_FACTOR_METHODS.SECURITY_KEY;
} else if (result.totp_enabled) {
secondFactorMethod = SECOND_FACTOR_METHODS.TOTP;
} else {
secondFactorMethod = SECOND_FACTOR_METHODS.BACKUP_CODE;
}
this.setProperties({
otherMethodAllowed: result.multiple_second_factor_methods,
secondFactorRequired: true,
showLoginButtons: false,
backupEnabled: result.backup_enabled,
showSecondFactor: result.totp_enabled,
totpEnabled: result.totp_enabled,
showSecondFactor: result.totp_enabled || result.backup_enabled,
showSecurityKey: result.security_key_enabled,
secondFactorMethod: result.security_key_enabled
? SECOND_FACTOR_METHODS.SECURITY_KEY
: SECOND_FACTOR_METHODS.TOTP,
securityKeyChallenge: result.challenge,
securityKeyAllowedCredentialIds: result.allowed_credential_ids,
secondFactorMethod,
});
// only need to focus the 2FA input for TOTP

View File

@ -420,7 +420,7 @@ export default Controller.extend({
}
},
resetSeenPopups() {
resetSeenUserTips() {
this.model.set("skip_new_user_tips", false);
this.model.set("seen_popups", null);
this.model.set("user_option.skip_new_user_tips", false);

View File

@ -564,8 +564,8 @@ export default Controller.extend(bufferedProperty("model"), {
return this.get("model.details").removeAllowedGroup(group);
},
deleteTopic() {
this.deleteTopic();
deleteTopic(opts = {}) {
this.deleteTopic(opts);
},
// Archive a PM (as opposed to archiving a topic)
@ -611,6 +611,10 @@ export default Controller.extend(bufferedProperty("model"), {
// Post related methods
replyToPost(post) {
if (this.currentUser) {
this.currentUser.hideUserTipForever("post_menu");
}
const composerController = this.composer;
const topic = post ? post.get("topic") : this.model;
const quoteState = this.quoteState;
@ -1522,7 +1526,11 @@ export default Controller.extend(bufferedProperty("model"), {
this.model.recover();
},
deleteTopic(opts) {
deleteTopic(opts = {}) {
if (opts.force_destroy) {
return this.model.destroy(this.currentUser, opts);
}
if (
this.model.views > this.siteSettings.min_topic_views_for_delete_confirm
) {

View File

@ -1,5 +1,6 @@
import { addComposerUploadPreProcessor } from "discourse/components/composer-editor";
import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
import { Promise } from "rsvp";
export default {
name: "register-media-optimization-upload-processor",
@ -11,10 +12,15 @@ export default {
UppyMediaOptimization,
({ isMobileDevice }) => {
return {
optimizeFn: (data, opts) =>
container
optimizeFn: (data, opts) => {
if (container.isDestroyed || container.isDestroying) {
return Promise.resolve();
}
return container
.lookup("service:media-optimization-worker")
.optimizeImage(data, opts),
.optimizeImage(data, opts);
},
runParallel: !isMobileDevice,
};
}

View File

@ -157,7 +157,6 @@ export function excerpt(cooked, length) {
resultLength += element.textContent.length;
}
} else if (element.tagName === "A") {
element.innerHTML = element.innerText;
result += element.outerHTML;
resultLength += element.innerText.length;
} else if (element.tagName === "IMG") {

View File

@ -117,15 +117,6 @@ const DiscourseURL = EmberObject.extend({
if (!holder) {
selector = holderId;
if (
document.getElementsByClassName(
`topic-post-visited-line post-${postNumber - 1}`
)?.length === 1
) {
selector = ".small-action.topic-post-visited";
}
holder = document.querySelector(selector);
}

View File

@ -6,8 +6,8 @@ import tippy from "tippy.js";
const instances = {};
const queue = [];
export function showPopup(options) {
hidePopup(options.id);
export function showUserTip(options) {
hideUserTip(options.id);
if (!options.reference) {
return;
@ -23,13 +23,14 @@ export function showPopup(options) {
showOnCreate: true,
hideOnClick: false,
trigger: "manual",
theme: "d-onboarding",
theme: "user-tips",
// It must be interactive to make buttons work.
interactive: true,
arrow: iconHTML("tippy-rounded-arrow"),
placement: options.placement,
appendTo: options.appendTo,
// It often happens for the reference element to be rerendered. In this
// case, tippy must be rerendered too. Having an animation means that the
@ -40,17 +41,15 @@ export function showPopup(options) {
allowHTML: true,
content: `
<div class='onboarding-popup-container'>
<div class='onboarding-popup-title'>${escape(options.titleText)}</div>
<div class='onboarding-popup-content'>${escape(
options.contentText
)}</div>
<div class='onboarding-popup-buttons'>
<div class='user-tip-container'>
<div class='user-tip-title'>${escape(options.titleText)}</div>
<div class='user-tip-content'>${escape(options.contentText)}</div>
<div class='user-tip-buttons'>
<button class="btn btn-primary btn-dismiss">${escape(
options.primaryBtnText || I18n.t("popup.primary")
options.primaryBtnText || I18n.t("user_tips.primary")
)}</button>
<button class="btn btn-flat btn-text btn-dismiss-all">${escape(
options.secondaryBtnText || I18n.t("popup.secondary")
options.secondaryBtnText || I18n.t("user_tips.secondary")
)}</button>
</div>
</div>`,
@ -73,12 +72,21 @@ export function showPopup(options) {
});
}
export function hidePopup(popupId) {
const instance = instances[popupId];
export function hideUserTip(userTipId) {
const instance = instances[userTipId];
if (instance && !instance.state.isDestroyed) {
instance.destroy();
}
delete instances[popupId];
delete instances[userTipId];
const index = queue.findIndex((userTip) => userTip.id === userTipId);
if (index > -1) {
queue.splice(index, 1);
}
}
export function hideAllUserTips() {
Object.keys(instances).forEach(hideUserTip);
}
function addToQueue(options) {
@ -92,9 +100,9 @@ function addToQueue(options) {
queue.push(options);
}
export function showNextPopup() {
export function showNextUserTip() {
const options = queue.shift();
if (options) {
showPopup(options);
showUserTip(options);
}
}

View File

@ -280,10 +280,12 @@ export default Mixin.create({
// note: we DO NOT use afterRender here cause _positionCard may
// run afterwards, if we allowed this to happen the usercard
// may be offscreen and we may scroll all the way to it on focus
discourseLater(() => {
const firstLink = this.element.querySelector("a");
firstLink && firstLink.focus();
}, 350);
if (event.pointerId === -1) {
discourseLater(() => {
const firstLink = this.element.querySelector("a");
firstLink && firstLink.focus();
}, 350);
}
}
});
},

View File

@ -143,10 +143,15 @@ const Composer = RestModel.extend({
const oldCategoryId = this._categoryId;
if (isEmpty(categoryId)) {
// Set General as the default category
const generalCategoryId = this.siteSettings.general_category_id;
// Check if there is a default composer category to set
const defaultComposerCategoryId = parseInt(
this.siteSettings.default_composer_category,
10
);
categoryId =
generalCategoryId && generalCategoryId > 0 ? generalCategoryId : null;
defaultComposerCategoryId && defaultComposerCategoryId > 0
? defaultComposerCategoryId
: null;
}
this._categoryId = categoryId;

View File

@ -207,7 +207,7 @@ const TopicTrackingState = EmberObject.extend({
}
}
if (filterTag && !data.payload.tags.includes(filterTag)) {
if (filterTag && !data.payload.tags?.includes(filterTag)) {
return;
}

View File

@ -43,7 +43,12 @@ import Evented from "@ember/object/evented";
import { cancel } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later";
import { isTesting } from "discourse-common/config/environment";
import { hidePopup, showNextPopup, showPopup } from "discourse/lib/popup";
import {
hideAllUserTips,
hideUserTip,
showNextUserTip,
showUserTip,
} from "discourse/lib/user-tips";
export const SECOND_FACTOR_METHODS = {
TOTP: 1,
@ -1090,57 +1095,70 @@ const User = RestModel.extend({
return [...trackedTags, ...watchedTags, ...watchingFirstPostTags];
},
showPopup(options) {
const popupTypes = Site.currentProp("onboarding_popup_types");
if (!popupTypes[options.id]) {
// eslint-disable-next-line no-console
console.warn("Cannot display popup with type =", options.id);
showUserTip(options) {
const userTips = Site.currentProp("user_tips");
if (!userTips || this.skip_new_user_tips) {
return;
}
const seenPopups = this.seen_popups || [];
if (seenPopups.includes(popupTypes[options.id])) {
if (!userTips[options.id]) {
if (!isTesting()) {
// eslint-disable-next-line no-console
console.warn("Cannot show user tip with type =", options.id);
}
return;
}
showPopup({
const seenUserTips = this.seen_popups || [];
if (
seenUserTips.includes(-1) ||
seenUserTips.includes(userTips[options.id])
) {
return;
}
showUserTip({
...options,
onDismiss: () => this.hidePopupForever(options.id),
onDismissAll: () => this.hidePopupForever(),
onDismiss: () => this.hideUserTipForever(options.id),
onDismissAll: () => this.hideUserTipForever(),
});
},
hidePopupForever(popupId) {
// Empty popupId means all popups.
const popupTypes = Site.currentProp("onboarding_popup_types");
if (popupId && !popupTypes[popupId]) {
// eslint-disable-next-line no-console
console.warn("Cannot hide popup with type =", popupId);
hideUserTipForever(userTipId) {
const userTips = Site.currentProp("user_tips");
if (!userTips || this.skip_new_user_tips) {
return;
}
// Hide any shown popups.
let seenPopups = this.seen_popups || [];
if (popupId) {
hidePopup(popupId);
if (!seenPopups.includes(popupTypes[popupId])) {
seenPopups.push(popupTypes[popupId]);
}
} else {
Object.keys(popupTypes).forEach(hidePopup);
seenPopups = Object.values(popupTypes);
// Empty userTipId means all user tips.
if (userTipId && !userTips[userTipId]) {
// eslint-disable-next-line no-console
console.warn("Cannot hide user tip with type =", userTipId);
return;
}
// Show next popup in queue.
showNextPopup();
// Hide any shown user tips.
let seenUserTips = this.seen_popups || [];
if (userTipId) {
hideUserTip(userTipId);
if (!seenUserTips.includes(userTips[userTipId])) {
seenUserTips.push(userTips[userTipId]);
}
} else {
hideAllUserTips();
seenUserTips = [-1];
}
// Save seen popups on the server.
// Show next user tip in queue.
showNextUserTip();
// Save seen user tips on the server.
if (!this.user_option) {
this.set("user_option", {});
}
this.set("seen_popups", seenPopups);
this.set("user_option.seen_popups", seenPopups);
if (popupId) {
this.set("seen_popups", seenUserTips);
this.set("user_option.seen_popups", seenUserTips);
if (userTipId) {
return this.save(["seen_popups"]);
} else {
this.set("skip_new_user_tips", true);

View File

@ -2,4 +2,4 @@
<p><TagChooser @tags={{this.tags}} @categoryId={{this.categoryId}} /></p>
<DButton @action={{this.action}} @disabled={{this.emptyTags}} @label={{concat "topics.bulk." this.label}} />
<DButton @action={{action this.action}} @disabled={{this.emptyTags}} @label={{concat "topics.bulk." this.label}} />

View File

@ -10,8 +10,7 @@
{{#unless this.showPositionInput}}
<section class="field position-disabled">
{{i18n "category.position_disabled"}}
<a href={{get-url "/admin/site_settings/category/basic"}}>{{i18n "category.position_disabled_click"}}</a>
{{html-safe (i18n "category.position_disabled" url=(get-url "/admin/site_settings/category/all_results?filter=fixed_category_positions"))}}
</section>
{{/unless}}

View File

@ -1,4 +1,6 @@
<div id="suggested-topics" class="suggested-topics" role="complementary" aria-labelledby="suggested-topics-title">
<UserTip @id="suggested_topics" />
<h3 id="suggested-topics-title" class="suggested-topics-title">
{{i18n this.suggestedTitleLabel}}
</h3>

View File

@ -29,6 +29,10 @@
<PinnedButton @pinned={{this.topic.pinned}} @topic={{this.topic}} />
{{#if this.showNotificationsButton}}
{{#if this.showNotificationUserTip}}
<UserTip @id="topic_notification_levels" @selector=".notifications-button" />
{{/if}}
<TopicNotificationsButton @notificationLevel={{this.topic.details.notification_level}} @topic={{this.topic}} />
{{/if}}

View File

@ -26,7 +26,7 @@
<div class="caps-lock-warning {{unless this.capsLockOn "hidden"}}">{{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}}</div>
</div>
</div>
<SecondFactorForm @secondFactorMethod={{this.secondFactorMethod}} @secondFactorToken={{this.secondFactorToken}} @class={{this.secondFactorClass}} @backupEnabled={{this.backupEnabled}} @isLogin={{true}}>
<SecondFactorForm @secondFactorMethod={{this.secondFactorMethod}} @secondFactorToken={{this.secondFactorToken}} @class={{this.secondFactorClass}} @backupEnabled={{this.backupEnabled}} @totpEnabled={{this.totpEnabled}} @isLogin={{true}}>
{{#if this.showSecurityKey}}
<SecurityKeyForm @allowedCredentialIds={{this.securityKeyAllowedCredentialIds}} @challenge={{this.securityKeyChallenge}} @showSecurityKey={{this.showSecurityKey}} @showSecondFactor={{this.showSecondFactor}} @secondFactorMethod={{this.secondFactorMethod}} @otherMethodAllowed={{this.otherMethodAllowed}} @action={{action "authenticateSecurityKey"}}>
</SecurityKeyForm>

View File

@ -117,8 +117,8 @@
<ComboBox @valueProperty="value" @content={{this.titleCountModes}} @value={{this.model.user_option.title_count_mode}} @id="user-title-count-mode" @onChange={{action (mut this.model.user_option.title_count_mode)}} />
</div>
<PreferenceCheckbox @labelKey="user.skip_new_user_tips.description" @checked={{this.model.user_option.skip_new_user_tips}} @class="pref-new-user-tips" />
{{#if this.site.onboarding_popup_types}}
<DButton @class="pref-reset-seen-popups" @action={{action "resetSeenPopups"}}>{{i18n "user.reset_seen_popups"}}</DButton>
{{#if this.site.user_tips}}
<DButton @class="pref-reset-seen-user-tips" @action={{action "resetSeenUserTips"}}>{{i18n "user.reset_seen_user_tips"}}</DButton>
{{/if}}
</fieldset>

View File

@ -13,7 +13,7 @@ import { wantsNewWindow } from "discourse/lib/intercept-click";
import { logSearchLinkClick } from "discourse/lib/search";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
import { hidePopup } from "discourse/lib/popup";
import { hideUserTip } from "discourse/lib/user-tips";
let _extraHeaderIcons = [];
@ -88,7 +88,7 @@ createWidget("header-notifications", {
const count = unread + reviewables;
if (count > 0) {
if (this._shouldHighlightAvatar()) {
if (this.siteSettings.enable_onboarding_popups) {
if (this.siteSettings.enable_user_tips) {
contents.push(h("span.ring"));
} else {
this._addAvatarHighlight(contents);
@ -124,7 +124,7 @@ createWidget("header-notifications", {
const unreadHighPriority = user.unread_high_priority_notifications;
if (!!unreadHighPriority) {
if (this._shouldHighlightAvatar()) {
if (this.siteSettings.enable_onboarding_popups) {
if (this.siteSettings.enable_user_tips) {
contents.push(h("span.ring"));
} else {
this._addAvatarHighlight(contents);
@ -198,32 +198,33 @@ createWidget("header-notifications", {
didRenderWidget() {
if (
!this.currentUser ||
!this.siteSettings.enable_onboarding_popups ||
!this.siteSettings.enable_user_tips ||
!this._shouldHighlightAvatar()
) {
return;
}
this.currentUser.showPopup({
this.currentUser.showUserTip({
id: "first_notification",
titleText: I18n.t("popup.first_notification.title"),
contentText: I18n.t("popup.first_notification.content"),
titleText: I18n.t("user_tips.first_notification.title"),
contentText: I18n.t("user_tips.first_notification.content"),
reference: document
.querySelector(".badge-notification")
.querySelector(".d-header .badge-notification")
?.parentElement?.querySelector(".avatar"),
appendTo: document.querySelector(".d-header .panel"),
placement: "bottom-end",
});
},
destroy() {
hidePopup("first_notification");
hideUserTip("first_notification");
},
willRerenderWidget() {
hidePopup("first_notification");
hideUserTip("first_notification");
},
});

View File

@ -711,6 +711,10 @@ export default createWidget("post-menu", {
},
showMoreActions() {
if (this.currentUser) {
this.currentUser.hideUserTipForever("post_menu");
}
this.state.collapsed = false;
const likesPromise = !this.state.likedUsers.length
? this.getWhoLiked()
@ -730,6 +734,8 @@ export default createWidget("post-menu", {
keyValueStore &&
keyValueStore.set({ key: "likedPostId", value: attrs.id });
return this.sendWidgetAction("showLogin");
} else {
this.currentUser.hideUserTipForever("post_menu");
}
if (this.capabilities.canVibrate && !isTesting()) {

View File

@ -25,6 +25,7 @@ import { transformBasicPost } from "discourse/lib/transform-post";
import autoGroupFlairForUser from "discourse/lib/avatar-flair";
import showModal from "discourse/lib/show-modal";
import { nativeShare } from "discourse/lib/pwa-utils";
import { hideUserTip } from "discourse/lib/user-tips";
function transformWithCallbacks(post) {
let transformed = transformBasicPost(post);
@ -593,6 +594,10 @@ createWidget("post-contents", {
},
share() {
if (this.currentUser) {
this.currentUser.hideUserTipForever("post_menu");
}
const post = this.findAncestorModel();
nativeShare(this.capabilities, { url: post.shareUrl }).catch(() => {
const topic = post.topic;
@ -928,4 +933,34 @@ export default createWidget("post", {
kvs.set({ key: "lastWarnedLikes", value: Date.now() });
}
},
didRenderWidget() {
if (!this.currentUser || !this.siteSettings.enable_user_tips) {
return;
}
const reference = document.querySelector(
".post-controls .actions .show-more-actions"
);
this.currentUser.showUserTip({
id: "post_menu",
titleText: I18n.t("user_tips.post_menu.title"),
contentText: I18n.t("user_tips.post_menu.content"),
reference,
appendTo: reference?.closest(".post-controls"),
placement: "top",
});
},
destroy() {
hideUserTip("post_menu");
},
willRerenderWidget() {
hideUserTip("post_menu");
},
});

View File

@ -367,7 +367,7 @@ export default createWidget("search-menu", {
return;
}
if (e.which === 65 /* a */) {
if (e.key === "A") {
if (document.activeElement?.classList.contains("search-link")) {
if (document.querySelector("#reply-control.open")) {
// add a link and focus composer
@ -388,8 +388,8 @@ export default createWidget("search-menu", {
}
}
const up = e.which === 38;
const down = e.which === 40;
const up = e.key === "ArrowUp";
const down = e.key === "ArrowDown";
if (up || down) {
let focused = document.activeElement.closest(".search-menu")
? document.activeElement
@ -443,7 +443,7 @@ export default createWidget("search-menu", {
}
const searchInput = document.querySelector("#search-term");
if (e.which === 13 && e.target === searchInput) {
if (e.key === "Enter" && e.target === searchInput) {
const recentEnterHit =
this.state._lastEnterTimestamp &&
Date.now() - this.state._lastEnterTimestamp < SECOND_ENTER_MAX_DELAY;
@ -463,7 +463,7 @@ export default createWidget("search-menu", {
this.state._lastEnterTimestamp = Date.now();
}
if (e.target === searchInput && e.which === 8 /* backspace */) {
if (e.target === searchInput && e.key === "Backspace") {
if (!searchInput.value) {
this.clearTopicContext();
this.clearPMInboxContext();

View File

@ -9,7 +9,7 @@ import discourseLater from "discourse-common/lib/later";
import { relativeAge } from "discourse/lib/formatter";
import renderTags from "discourse/lib/render-tags";
import renderTopicFeaturedLink from "discourse/lib/render-topic-featured-link";
import { hidePopup } from "discourse/lib/popup";
import { hideUserTip } from "discourse/lib/user-tips";
const SCROLLER_HEIGHT = 50;
const LAST_READ_HEIGHT = 20;
@ -601,27 +601,28 @@ export default createWidget("topic-timeline", {
},
didRenderWidget() {
if (!this.currentUser || !this.siteSettings.enable_onboarding_popups) {
if (!this.currentUser || !this.siteSettings.enable_user_tips) {
return;
}
this.currentUser.showPopup({
this.currentUser.showUserTip({
id: "topic_timeline",
titleText: I18n.t("popup.topic_timeline.title"),
contentText: I18n.t("popup.topic_timeline.content"),
titleText: I18n.t("user_tips.topic_timeline.title"),
contentText: I18n.t("user_tips.topic_timeline.content"),
reference: document.querySelector("div.timeline-scrollarea-wrapper"),
appendTo: document.querySelector("div.topic-timeline"),
placement: "left",
});
},
destroy() {
hidePopup("topic_timeline");
hideUserTip("topic_timeline");
},
willRerenderWidget() {
hidePopup("topic_timeline");
hideUserTip("topic_timeline");
},
});

View File

@ -6,6 +6,7 @@ const mergeTrees = require("broccoli-merge-trees");
const concat = require("broccoli-concat");
const prettyTextEngine = require("./lib/pretty-text-engine");
const { createI18nTree } = require("./lib/translation-plugin");
const { parsePluginClientSettings } = require("./lib/site-settings-plugin");
const discourseScss = require("./lib/discourse-scss");
const generateScriptsTree = require("./lib/scripts");
const funnel = require("broccoli-funnel");
@ -57,6 +58,13 @@ module.exports = function (defaults) {
autoImport: {
forbidEval: true,
insertScriptsAt: "ember-auto-import-scripts",
webpack: {
// Workarounds for https://github.com/ef4/ember-auto-import/issues/519 and https://github.com/ef4/ember-auto-import/issues/478
devtool: isProduction ? false : "source-map", // Sourcemaps contain reference to the ephemeral broccoli cache dir, which changes on every deploy
optimization: {
moduleIds: "size", // Consistent module references https://github.com/ef4/ember-auto-import/issues/478#issuecomment-1000526638
},
},
},
fingerprint: {
// Handled by Rails asset pipeline
@ -161,6 +169,7 @@ module.exports = function (defaults) {
return mergeTrees([
createI18nTree(discourseRoot, vendorJs),
parsePluginClientSettings(discourseRoot, vendorJs, app),
app.toTree(),
funnel(`${discourseRoot}/public/javascripts`, { destDir: "javascripts" }),
funnel(`${vendorJs}/highlightjs`, {

View File

@ -4,6 +4,7 @@
"baseUrl": ".",
"paths": {
"discourse/*": ["./app/*"],
"discourse/tests/*": ["./tests/*"],
"discourse-common/*": ["../discourse-common/addon/*"],
"pretty-text/*": ["../pretty-text/addon/*"],
"select-kit/*": ["../select-kit/addon/*"],

View File

@ -0,0 +1,96 @@
const Plugin = require("broccoli-plugin");
const Yaml = require("js-yaml");
const fs = require("fs");
const concat = require("broccoli-concat");
const mergeTrees = require("broccoli-merge-trees");
const deepmerge = require("deepmerge");
const glob = require("glob");
const { shouldLoadPluginTestJs } = require("discourse/lib/plugin-js");
let built = false;
class SiteSettingsPlugin extends Plugin {
constructor(inputNodes, inputFile, options) {
super(inputNodes, {
...options,
persistentOutput: true,
});
}
build() {
if (built) {
return;
}
let parsed = {};
this.inputPaths.forEach((path) => {
let inputFile;
if (path.includes("plugins")) {
inputFile = "settings.yml";
} else {
inputFile = "site_settings.yml";
}
const file = path + "/" + inputFile;
let yaml;
try {
yaml = fs.readFileSync(file, { encoding: "UTF-8" });
} catch (err) {
// the plugin does not have a config file, go to the next file
return;
}
const loaded = Yaml.load(yaml, { json: true });
parsed = deepmerge(parsed, loaded);
});
let clientSettings = {};
// eslint-disable-next-line no-unused-vars
for (const [category, settings] of Object.entries(parsed)) {
for (const [setting, details] of Object.entries(settings)) {
if (details.client) {
clientSettings[setting] = details.default;
}
}
}
const contents = `var CLIENT_SITE_SETTINGS_WITH_DEFAULTS = ${JSON.stringify(
clientSettings
)}`;
fs.writeFileSync(`${this.outputPath}/` + "settings_out.js", contents);
built = true;
}
}
module.exports = function siteSettingsPlugin(...params) {
return new SiteSettingsPlugin(...params);
};
module.exports.parsePluginClientSettings = function (
discourseRoot,
vendorJs,
app
) {
let settings = [discourseRoot + "/config"];
if (shouldLoadPluginTestJs()) {
const pluginInfos = app.project
.findAddonByName("discourse-plugins")
.pluginInfos();
pluginInfos.forEach(({ hasConfig, configDirectory }) => {
if (hasConfig) {
settings = settings.concat(glob.sync(configDirectory));
}
});
}
const loadedSettings = new SiteSettingsPlugin(settings, "site_settings.yml");
return concat(mergeTrees([loadedSettings]), {
inputFiles: [],
headerFiles: [],
footerFiles: [],
outputFile: `assets/test-site-settings.js`,
});
};
module.exports.SiteSettingsPlugin = SiteSettingsPlugin;

View File

@ -16,8 +16,8 @@
"test": "ember test"
},
"dependencies": {
"@babel/core": "^7.19.6",
"@babel/standalone": "^7.20.1",
"@babel/core": "^7.20.2",
"@babel/standalone": "^7.20.4",
"@discourse/backburner.js": "^2.7.1-0",
"@discourse/itsatrap": "^2.0.10",
"@ember/jquery": "^2.0.0",
@ -80,19 +80,18 @@
"markdown-it": "^13.0.1",
"message-bus-client": "^4.2.0",
"messageformat": "0.1.5",
"node-fetch": "^2.6.6",
"node-fetch": "^2.6.7",
"pretender": "^3.4.7",
"pretty-text": "workspace:*",
"qunit": "^2.19.3",
"qunit-dom": "^2.0.0",
"sass": "^1.55.0",
"sass": "^1.56.1",
"select-kit": "workspace:*",
"sinon": "^14.0.1",
"sinon": "^14.0.2",
"source-map": "^0.6.1",
"terser": "5.10.0",
"tippy.js": "^6.3.7",
"virtual-dom": "^2.1.1",
"webpack": "^5.74.0",
"webpack": "^5.75.0",
"wizard": "workspace:*",
"xss": "^1.0.14"
},

View File

@ -0,0 +1,56 @@
import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
import { fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance("Admin - Permalinks", function (needs) {
const startingData = [
{
id: 38,
url: "c/feature/announcements",
topic_id: null,
topic_title: null,
topic_url: null,
post_id: null,
post_url: null,
post_number: null,
post_topic_title: null,
category_id: 67,
category_name: "announcements",
category_url: "/c/announcements/67",
external_url: null,
tag_id: null,
tag_name: null,
tag_url: null,
},
];
needs.user();
needs.pretender((server, helper) => {
server.get("/admin/permalinks.json", (response) => {
const result =
response.queryParams.filter !== "feature" ? [] : startingData;
return helper.response(200, result);
});
});
test("search permalinks with result", async function (assert) {
await visit("/admin/customize/permalinks");
await fillIn(".permalink-search input", "feature");
assert.ok(
exists(".permalink-results span[title='c/feature/announcements']"),
"permalink is found after search"
);
});
test("search permalinks without results", async function (assert) {
await visit("/admin/customize/permalinks");
await fillIn(".permalink-search input", "garboogle");
assert.ok(
exists(".permalink-results__no-result"),
"no results message shown"
);
assert.ok(exists(".permalink-search"), "search input still visible");
});
});

View File

@ -0,0 +1,51 @@
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance("Admin - Plugins", function (needs) {
needs.user();
needs.pretender((server, helper) => {
server.get("/admin/plugins", () =>
helper.response({
plugins: [
{
id: "some-test-plugin",
name: "some-test-plugin",
about: "Plugin description",
version: "0.1",
url: "https://example.com",
admin_route: {
location: "testlocation",
label: "test.plugin.label",
full_location: "adminPlugins.testlocation",
},
enabled: true,
enabled_setting: "testplugin_enabled",
has_settings: true,
is_official: true,
},
],
})
);
});
test("shows plugin list", async function (assert) {
await visit("/admin/plugins");
const table = query("table.admin-plugins");
assert.strictEqual(
table.querySelector("tr .plugin-name .name").innerText,
"some-test-plugin",
"displays the plugin in the table"
);
assert.true(
exists(".admin-plugins .admin-detail .alert-error"),
"displays an error for unknown routes"
);
});
});

View File

@ -75,11 +75,21 @@ acceptance("Admin - Watched Words", function (needs) {
test("add case-sensitive words", async function (assert) {
await visit("/admin/customize/watched_words/action/block");
const submitButton = query(".watched-word-form button");
assert.strictEqual(
submitButton.disabled,
true,
"Add button is disabled by default"
);
await click(".show-words-checkbox");
await fillIn(".watched-word-form input", "Discourse");
await click(".case-sensitivity-checkbox");
await click(".watched-word-form button");
assert.strictEqual(
submitButton.disabled,
false,
"Add button should no longer be disabled after input is filled"
);
await click(submitButton);
assert
.dom(".watched-words-list .watched-word")
@ -87,7 +97,7 @@ acceptance("Admin - Watched Words", function (needs) {
await fillIn(".watched-word-form input", "discourse");
await click(".case-sensitivity-checkbox");
await click(".watched-word-form button");
await click(submitButton);
assert
.dom(".watched-words-list .watched-word")

View File

@ -1,10 +1,9 @@
import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { click, currentURL, settled, visit } from "@ember/test-helpers";
import { set } from "@ember/object";
acceptance("Bootstrap Mode Notice", function (needs) {
needs.user();
needs.user({ admin: true });
needs.site({ wizard_required: true });
needs.settings({
bootstrap_mode_enabled: true,
@ -36,8 +35,8 @@ acceptance("Bootstrap Mode Notice", function (needs) {
"it transitions to the wizard page"
);
this.siteSettings.bootstrap_mode_enabled = false;
await visit("/");
set(this.siteSettings, "bootstrap_mode_enabled", false);
await settled();
assert.ok(
!exists(".bootstrap-mode-notice"),

View File

@ -24,7 +24,7 @@ acceptance("Composer Actions", function (needs) {
});
needs.settings({
prioritize_username_in_ux: true,
display_name_on_post: false,
display_name_on_posts: false,
enable_whispers: true,
});
needs.site({ can_tag_topics: true });
@ -489,7 +489,7 @@ acceptance("Prioritize Username", function (needs) {
needs.user();
needs.settings({
prioritize_username_in_ux: true,
display_name_on_post: false,
display_name_on_posts: false,
});
test("Reply to post use username", async function (assert) {
@ -517,7 +517,7 @@ acceptance("Prioritize Full Name", function (needs) {
needs.user();
needs.settings({
prioritize_username_in_ux: false,
display_name_on_post: true,
display_name_on_posts: true,
});
test("Reply to post use full name", async function (assert) {
@ -555,7 +555,7 @@ acceptance("Prioritizing Name fall back", function (needs) {
needs.user();
needs.settings({
prioritize_username_in_ux: false,
display_name_on_post: true,
display_name_on_posts: true,
});
test("Quotes fall back to username if name is not present", async function (assert) {

View File

@ -18,7 +18,7 @@ acceptance("Composer - editor mentions", function (needs) {
};
needs.user();
needs.settings({ enable_mentions: true });
needs.settings({ enable_mentions: true, allow_uncategorized_topics: true });
needs.hooks.afterEach(() => {
if (clock) {

View File

@ -11,7 +11,7 @@ import { test } from "qunit";
acceptance("Composer - Image Preview", function (needs) {
needs.user();
needs.settings({ enable_whispers: true });
needs.settings({ enable_whispers: true, allow_uncategorized_topics: true });
needs.site({ can_tag_topics: true });
needs.pretender((server, helper) => {
server.post("/uploads/lookup-urls", () => {

View File

@ -18,6 +18,7 @@ acceptance("Composer - Tags", function (needs) {
});
});
needs.site({ can_tag_topics: true });
needs.settings({ allow_uncategorized_topics: true });
test("staff bypass tag validation rule", async function (assert) {
await visit("/");

View File

@ -41,6 +41,7 @@ acceptance("Composer", function (needs) {
needs.settings({
enable_whispers: true,
general_category_id: 1,
default_composer_category: 1,
});
needs.site({
can_tag_topics: true,
@ -90,7 +91,7 @@ acceptance("Composer", function (needs) {
test("Composer is opened", async function (assert) {
await visit("/");
await click("#create-topic");
// Check that General category is selected
// Check that the default category is selected
assert.strictEqual(selectKit(".category-chooser").header().value(), "1");
assert.strictEqual(
@ -302,7 +303,7 @@ acceptance("Composer", function (needs) {
await visit("/");
await click("#create-topic");
await fillIn("#reply-title", "This title doesn't matter");
await fillIn(".d-editor-input", "custom message");
await fillIn(".d-editor-input", "custom message that is a good length");
await click("#reply-control button.create");
assert.strictEqual(
@ -1107,6 +1108,7 @@ acceptance("Composer - Customizations", function (needs) {
acceptance("Composer - Focus Open and Closed", function (needs) {
needs.user();
needs.settings({ allow_uncategorized_topics: true });
test("Focusing a composer which is not open with create topic", async function (assert) {
await visit("/t/internationalization-localization/280");
@ -1200,3 +1202,109 @@ acceptance("Composer - Focus Open and Closed", function (needs) {
);
});
});
// Default Composer Category tests
acceptance("Composer - Default category", function (needs) {
needs.user();
needs.settings({
general_category_id: 1,
default_composer_category: 2,
});
needs.site({
categories: [
{
id: 1,
name: "General",
slug: "general",
permission: 1,
ltopic_template: null,
},
{
id: 2,
name: "test too",
slug: "test-too",
permission: 1,
topic_template: null,
},
],
});
test("Default category is selected over general category", async function (assert) {
await visit("/");
await click("#create-topic");
assert.strictEqual(selectKit(".category-chooser").header().value(), "2");
assert.strictEqual(
selectKit(".category-chooser").header().name(),
"test too"
);
});
});
acceptance("Composer - Uncategorized category", function (needs) {
needs.user();
needs.settings({
general_category_id: -1, // For sites that never had this seeded
default_composer_category: -1, // For sites that never had this seeded
allow_uncategorized_topics: true,
});
needs.site({
categories: [
{
id: 1,
name: "General",
slug: "general",
permission: 1,
ltopic_template: null,
},
{
id: 2,
name: "test too",
slug: "test-too",
permission: 1,
topic_template: null,
},
],
});
test("Uncategorized category is selected", async function (assert) {
await visit("/");
await click("#create-topic");
assert.strictEqual(selectKit(".category-chooser").header().value(), null);
});
});
acceptance("Composer - default category not set", function (needs) {
needs.user();
needs.settings({
default_composer_category: "",
});
needs.site({
categories: [
{
id: 1,
name: "General",
slug: "general",
permission: 1,
ltopic_template: null,
},
{
id: 2,
name: "test too",
slug: "test-too",
permission: 1,
topic_template: null,
},
],
});
test("Nothing is selected", async function (assert) {
await visit("/");
await click("#create-topic");
assert.strictEqual(selectKit(".category-chooser").header().value(), null);
assert.strictEqual(
selectKit(".category-chooser").header().name(),
"category&hellip;"
);
});
});
// END: Default Composer Category tests

View File

@ -1,5 +1,6 @@
import {
acceptance,
chromeTest,
createFile,
loggedInUser,
paste,
@ -81,6 +82,7 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
needs.settings({
simultaneous_uploads: 2,
enable_rich_text_paste: true,
allow_uncategorized_topics: true,
});
needs.hooks.afterEach(() => {
uploadNumber = 1;
@ -113,34 +115,39 @@ acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) {
appEvents.trigger("composer:add-files", image);
});
test("should handle adding one file for upload then adding another when the first is still in progress", async function (assert) {
await visit("/");
await click("#create-topic");
await fillIn(".d-editor-input", "The image:\n");
const appEvents = loggedInUser().appEvents;
const done = assert.async();
// TODO: On Firefox Evergreen this often fails, because the order of uploads
// in markdown is reversed
chromeTest(
"handles adding one file for upload then adding another when the first is still in progress",
async function (assert) {
await visit("/");
await click("#create-topic");
await fillIn(".d-editor-input", "The image:\n");
const appEvents = loggedInUser().appEvents;
const done = assert.async();
appEvents.on("composer:all-uploads-complete", async () => {
await settled();
assert.strictEqual(
query(".d-editor-input").value,
"The image:\n![avatar.PNG|690x320](upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg)\n![avatar2.PNG|690x320](upload://sdfljsdfgjlkwg4328.jpeg)\n"
);
done();
});
appEvents.on("composer:all-uploads-complete", async () => {
await settled();
assert.strictEqual(
query(".d-editor-input").value,
"The image:\n![avatar.PNG|690x320](upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg)\n![avatar2.PNG|690x320](upload://sdfljsdfgjlkwg4328.jpeg)\n"
);
done();
});
let image2Added = false;
appEvents.on("composer:upload-started", () => {
if (!image2Added) {
appEvents.trigger("composer:add-files", image2);
image2Added = true;
}
});
let image2Added = false;
appEvents.on("composer:upload-started", () => {
if (!image2Added) {
appEvents.trigger("composer:add-files", image2);
image2Added = true;
}
});
const image1 = createFile("avatar.png");
const image2 = createFile("avatar2.png");
appEvents.trigger("composer:add-files", image1);
});
const image1 = createFile("avatar.png");
const image2 = createFile("avatar2.png");
appEvents.trigger("composer:add-files", image1);
}
);
test("should handle placeholders correctly even if the OS rewrites ellipses", async function (assert) {
const execCommand = document.execCommand;
@ -487,6 +494,7 @@ acceptance("Uppy Composer Attachment - Upload Error", function (needs) {
});
needs.settings({
simultaneous_uploads: 2,
allow_uncategorized_topics: true,
});
test("should show an error message for the failed upload", async function (assert) {
@ -527,6 +535,7 @@ acceptance("Uppy Composer Attachment - Upload Handler", function (needs) {
needs.pretender(pretender);
needs.settings({
simultaneous_uploads: 2,
allow_uncategorized_topics: true,
});
needs.hooks.beforeEach(() => {
withPluginApi("0.8.14", (api) => {

View File

@ -21,7 +21,7 @@ acceptance("Emoji", function (needs) {
assert.strictEqual(
normalizeHtml(query(".d-editor-preview").innerHTML.trim()),
normalizeHtml(
`<p>this is an emoji <img src="/images/emoji/google_classic/blonde_woman.png?v=${v}" title=":blonde_woman:" class="emoji" alt=":blonde_woman:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
`<p>this is an emoji <img src="/images/emoji/twitter/blonde_woman.png?v=${v}" title=":blonde_woman:" class="emoji" alt=":blonde_woman:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
)
);
});
@ -36,7 +36,7 @@ acceptance("Emoji", function (needs) {
assert.strictEqual(
normalizeHtml(query(".d-editor-preview").innerHTML.trim()),
normalizeHtml(
`<p>this is an emoji <img src="/images/emoji/google_classic/blonde_woman/5.png?v=${v}" title=":blonde_woman:t5:" class="emoji" alt=":blonde_woman:t5:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
`<p>this is an emoji <img src="/images/emoji/twitter/blonde_woman/5.png?v=${v}" title=":blonde_woman:t5:" class="emoji" alt=":blonde_woman:t5:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
)
);
});

View File

@ -94,7 +94,7 @@ acceptance("Group Requests", function (needs) {
query(".group-members tr:first-child td:nth-child(1)")
.innerText.trim()
.replace(/\s+/g, " "),
"Robin Ward eviltrout"
"eviltrout Robin Ward"
);
assert.strictEqual(
query(".group-members tr:first-child td:nth-child(3)").innerText.trim(),

View File

@ -123,7 +123,7 @@ acceptance("Invite accept", function (needs) {
"submit is disabled because password is not filled"
);
await fillIn("#new-account-password", "top$ecret");
await fillIn("#new-account-password", "top$ecretzz");
assert.notOk(
exists(".invites-show .btn-primary:disabled"),
"submit is enabled"

View File

@ -1,8 +1,4 @@
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
@ -10,15 +6,6 @@ import { visit } from "@ember/test-helpers";
acceptance("Personal Message", function (needs) {
needs.user();
test("footer edit button", async function (assert) {
await visit("/t/pm-for-testing/12");
assert.ok(
!exists(".edit-message"),
"does not show edit first post button on footer by default"
);
});
test("suggested messages", async function (assert) {
await visit("/t/pm-for-testing/12");

View File

@ -18,7 +18,7 @@ acceptance("Plugin Outlet - Connector Class", function (needs) {
extraConnectorClass("user-profile-primary/hello", {
actions: {
sayHello() {
this.set("hello", "hello!");
this.set("hello", `${this.hello || ""}hello!`);
},
},
});
@ -53,6 +53,7 @@ acceptance("Plugin Outlet - Connector Class", function (needs) {
`${PREFIX}/user-profile-primary/hello`
] = hbs`<span class='hello-username'>{{model.username}}</span>
<button class='say-hello' {{on "click" (action "sayHello")}}></button>
<button class='say-hello-using-this' {{on "click" this.sayHello}}></button>
<span class='hello-result'>{{hello}}</span>`;
Ember.TEMPLATES[
`${PREFIX}/user-profile-primary/hi`
@ -87,6 +88,12 @@ acceptance("Plugin Outlet - Connector Class", function (needs) {
"hello!",
"actions delegate properly"
);
await click(".say-hello-using-this");
assert.strictEqual(
query(".hello-result").innerText,
"hello!hello!",
"actions are made available on `this` and are bound correctly"
);
await click(".say-hi");
assert.strictEqual(

View File

@ -128,6 +128,9 @@ acceptance("User Preferences", function (needs) {
await categorySelector.fillInFilter("faq");
await savePreferences();
this.siteSettings.tagging_enabled = false;
await visit("/");
await visit("/u/eviltrout/preferences");
assert.ok(
!exists(".preferences-nav .nav-tags a"),
"tags tab isn't there when tags are disabled"

View File

@ -4,7 +4,13 @@ import {
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers";
import {
click,
fillIn,
settled,
triggerKeyEvent,
visit,
} from "@ember/test-helpers";
import I18n from "I18n";
import searchFixtures from "discourse/tests/fixtures/search-fixtures";
import selectKit from "discourse/tests/helpers/select-kit-helper";
@ -333,7 +339,10 @@ acceptance("Search - Anonymous", function (needs) {
acceptance("Search - Authenticated", function (needs) {
needs.user();
needs.settings({ log_search_queries: true });
needs.settings({
log_search_queries: true,
allow_uncategorized_topics: true,
});
needs.pretender((server, helper) => {
server.get("/search/query", (request) => {
@ -476,6 +485,7 @@ acceptance("Search - Authenticated", function (needs) {
"href"
);
await triggerKeyEvent(".search-menu", "keydown", "A");
await settled();
assert.strictEqual(
query("#reply-control textarea").value,

View File

@ -44,6 +44,12 @@ const RESPONSES = {
security_keys_enabled: true,
allowed_methods: [BACKUP_CODE],
},
ok010010: {
totp_enabled: false,
backup_enabled: true,
security_keys_enabled: false,
allowed_methods: [BACKUP_CODE],
},
};
Object.keys(RESPONSES).forEach((k) => {
@ -178,6 +184,14 @@ acceptance("Second Factor Auth Page", function (needs) {
!exists(".toggle-second-factor-method"),
"no alternative methods are shown if only 1 method is allowed"
);
// only backup codes
await visit("/session/2fa?nonce=ok010010");
assert.ok(exists("form.backup-code-token"), "backup code form is shown");
assert.ok(
!exists(".toggle-second-factor-method"),
"no alternative methods are shown if only 1 method is allowed"
);
});
test("switching 2FA methods", async function (assert) {

View File

@ -19,11 +19,12 @@ acceptance("Sidebar - Plugin API", function (needs) {
});
needs.hooks.afterEach(() => {
linkDidInsert = undefined;
linkDestroy = undefined;
sectionDestroy = undefined;
});
let linkDestroy, sectionDestroy;
let linkDidInsert, linkDestroy, sectionDestroy;
test("Multiple header actions and links", async function (assert) {
withPluginApi("1.3.0", (api) => {
@ -117,6 +118,11 @@ acceptance("Sidebar - Plugin API", function (needs) {
return "unread";
}
@bind
didInsert() {
linkDidInsert = "link test";
}
@bind
willDestroy() {
linkDestroy = "link test";
@ -201,6 +207,12 @@ acceptance("Sidebar - Plugin API", function (needs) {
await visit("/");
assert.strictEqual(
linkDidInsert,
"link test",
"calls link didInsert function"
);
assert.strictEqual(
query(
".sidebar-section-test-chat-channels .sidebar-section-header-text"

View File

@ -69,6 +69,7 @@ acceptance("Sidebar - Logged on user - Categories Section", function (needs) {
enable_experimental_sidebar_hamburger: true,
enable_sidebar: true,
suppress_uncategorized_badge: false,
allow_uncategorized_topics: true,
});
needs.pretender((server, helper) => {

View File

@ -30,7 +30,7 @@ acceptance("Topic Discovery", function (needs) {
assert.strictEqual(
query("a[data-user-card=eviltrout] img.avatar").getAttribute("title"),
"Evil Trout - Most Posts",
"eviltrout - Most Posts",
"it shows user's full name in avatar title"
);

View File

@ -134,7 +134,7 @@ acceptance("Topic - Edit timer", function (needs) {
await timerType.expand();
await timerType.selectRowByValue("publish_to_category");
assert.strictEqual(categoryChooser.header().label(), "uncategorized");
assert.strictEqual(categoryChooser.header().label(), "category…");
assert.strictEqual(categoryChooser.header().value(), null);
await categoryChooser.expand();
@ -174,7 +174,7 @@ acceptance("Topic - Edit timer", function (needs) {
await timerType.expand();
await timerType.selectRowByValue("publish_to_category");
assert.strictEqual(categoryChooser.header().label(), "uncategorized");
assert.strictEqual(categoryChooser.header().label(), "category…");
assert.strictEqual(categoryChooser.header().value(), null);
await categoryChooser.expand();
@ -218,7 +218,7 @@ acceptance("Topic - Edit timer", function (needs) {
await timerType.expand();
await timerType.selectRowByValue("publish_to_category");
assert.strictEqual(categoryChooser.header().label(), "uncategorized");
assert.strictEqual(categoryChooser.header().label(), "category…");
assert.strictEqual(categoryChooser.header().value(), null);
await categoryChooser.expand();

View File

@ -20,6 +20,19 @@ acceptance("Topic - Quote button - logged in", function (needs) {
share_quote_buttons: "twitter|email",
});
needs.pretender((server, helper) => {
server.get("/inline-onebox", () =>
helper.response({
"inline-oneboxes": [
{
url: "http://www.example.com/57350945",
title: "This is a great title",
},
],
})
);
});
chromeTest(
"Does not show the quote share buttons by default",
async function (assert) {

View File

@ -211,6 +211,7 @@ acceptance("Topic", function (needs) {
});
test("Deleting a topic", async function (assert) {
this.siteSettings.min_topic_views_for_delete_confirm = 10000;
await visit("/t/internationalization-localization/280");
await click(".topic-post:nth-of-type(1) button.show-more-actions");
await click(".widget-button.delete");

View File

@ -58,7 +58,7 @@ acceptance("User Drafts", function (needs) {
query(".user-stream-item:nth-child(3) .excerpt").innerHTML.trim()
),
normalizeHtml(
`here goes a reply to a PM <img src="/images/emoji/google_classic/slight_smile.png?v=${IMAGE_VERSION}" title=":slight_smile:" class="emoji" alt=":slight_smile:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;">`
`here goes a reply to a PM <img src="/images/emoji/twitter/slight_smile.png?v=${IMAGE_VERSION}" title=":slight_smile:" class="emoji" alt=":slight_smile:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;">`
),
"shows the excerpt"
);

View File

@ -144,18 +144,18 @@ acceptance("User Preferences - Interface", function (needs) {
document.querySelector("meta[name='discourse_theme_id']").remove();
});
test("shows reset seen onboarding popups button", async function (assert) {
test("shows reset seen user tips popups button", async function (assert) {
let site = Site.current();
site.set("onboarding_popup_types", { first_notification: 1 });
site.set("user_tips", { first_notification: 1 });
await visit("/u/eviltrout/preferences/interface");
assert.ok(
exists(".pref-reset-seen-popups"),
"has reset seen popups button"
exists(".pref-reset-seen-user-tips"),
"has reset seen user tips button"
);
await click(".pref-reset-seen-popups");
await click(".pref-reset-seen-user-tips");
assert.deepEqual(lastUserData, {
seen_popups: "",

View File

@ -120,14 +120,16 @@ acceptance(
await visit("/u/eviltrout");
assert.strictEqual(
query(".user-profile-names .username").textContent.trim(),
"eviltrout",
`eviltrout
Robin Ward is an admin`,
"eviltrout profile is shown"
);
await visit("/u/e.il.rout");
assert.strictEqual(
query(".user-profile-names .username").textContent.trim(),
"e.il.rout",
`e.il.rout
Robin Ward is an admin`,
"e.il.rout profile is shown"
);
});

View File

@ -0,0 +1,96 @@
import { visit } from "@ember/test-helpers";
import { hideAllUserTips } from "discourse/lib/user-tips";
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
import { test } from "qunit";
acceptance("User Tips - first_notification", function (needs) {
needs.user({ unread_high_priority_notifications: 1 });
needs.site({ user_tips: { first_notification: 1 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows first notification user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.first_notification.title")
);
});
});
acceptance("User Tips - topic_timeline", function (needs) {
needs.user();
needs.site({ user_tips: { topic_timeline: 2 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows topic timeline user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.topic_timeline.title")
);
});
});
acceptance("User Tips - post_menu", function (needs) {
needs.user();
needs.site({ user_tips: { post_menu: 3 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows post menu user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.post_menu.title")
);
});
});
acceptance("User Tips - topic_notification_levels", function (needs) {
needs.user();
needs.site({ user_tips: { topic_notification_levels: 4 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows post menu user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.topic_notification_levels.title")
);
});
});
acceptance("User Tips - suggested_topics", function (needs) {
needs.user();
needs.site({ user_tips: { suggested_topics: 5 } });
needs.hooks.beforeEach(() => hideAllUserTips());
needs.hooks.afterEach(() => hideAllUserTips());
test("Shows post menu user tip", async function (assert) {
this.siteSettings.enable_user_tips = true;
await visit("/t/internationalization-localization/280");
assert.equal(
query(".user-tip-title").textContent.trim(),
I18n.t("user_tips.suggested_topics.title")
);
});
});

View File

@ -612,7 +612,22 @@ export function applyDefaultHandlers(pretender) {
pretender.post("/posts", function (request) {
const data = parsePostData(request.requestBody);
if (data.raw === "custom message") {
if (data.title === "this title triggers an error") {
return response(422, { errors: ["That title has already been taken"] });
}
if (data.raw === "enqueue this content please") {
return response(200, {
success: true,
action: "enqueued",
pending_post: {
id: 1234,
raw: data.raw,
},
});
}
if (data.raw === "custom message that is a good length") {
return response(200, {
success: true,
action: "custom",

View File

@ -1,111 +1,27 @@
const ORIGINAL_SETTINGS = {
const CLIENT_SETTING_TEST_OVERRIDES = {
title: "QUnit Discourse Tests",
site_logo_url: "/assets/logo.png",
site_logo_url: "/assets/logo.png",
site_logo_small_url: "/assets/logo-single.png",
site_mobile_logo_url: "",
site_favicon_url: "/images/discourse-logo-sketch-small.png",
allow_user_locale: false,
suggested_topics: 7,
ga_universal_tracking_code: "",
ga_universal_domain_name: "auto",
top_menu: "latest|new|unread|categories|top",
post_menu: "like|share|flag|edit|bookmark|delete|admin|reply",
post_menu_hidden_items: "flag|bookmark|edit|delete|admin",
share_links: "twitter|facebook|email",
allow_username_in_share_links: true,
category_colors:
"BF1E2E|F1592A|F7941D|9EB83B|3AB54A|12A89D|25AAE2|0E76BD|652D90|92278F|ED207B|8C6238|231F20|27AA5B|B3B5B4|E45735",
enable_mobile_theme: true,
relative_date_duration: 14,
fixed_category_positions: false,
enable_badges: true,
invite_only: false,
login_required: false,
must_approve_users: false,
enable_local_logins: true,
allow_new_registrations: true,
enable_google_logins: true,
enable_google_oauth2_logins: false,
enable_twitter_logins: true,
enable_facebook_logins: true,
enable_github_logins: true,
enable_discourse_connect: false,
min_username_length: 3,
max_username_length: 20,
min_password_length: 8,
enable_names: true,
invites_shown: 30,
delete_user_max_post_age: 60,
delete_all_posts_max: 15,
min_post_length: 20,
min_personal_message_post_length: 10,
max_post_length: 32000,
min_topic_title_length: 15,
max_topic_title_length: 255,
min_personal_message_title_length: 2,
allow_uncategorized_topics: true,
min_title_similar_length: 10,
edit_history_visible_to_public: true,
delete_removed_posts_after: 24,
traditional_markdown_linebreaks: false,
suppress_reply_directly_below: true,
suppress_reply_directly_above: true,
newuser_max_embedded_media: 0,
newuser_max_attachments: 0,
display_name_on_posts: true,
short_progress_text_threshold: 10000,
default_code_lang: "auto",
autohighlight_all_code: false,
email_in: false,
authorized_extensions: ".jpg|.jpeg|.png|.gif|.svg|.txt|.ico|.yml",
authorized_extensions_for_staff: "",
max_image_width: 690,
max_image_height: 500,
allow_profile_backgrounds: true,
allow_uploaded_avatars: "0",
tl1_requires_read_posts: 30,
polling_interval: 3000,
authorized_extensions: "jpg|jpeg|png|gif|heic|heif|webp|svg|txt|ico|yml",
anon_polling_interval: 30000,
flush_timings_secs: 5,
enable_user_directory: true,
tos_url: "",
privacy_policy_url: "",
tos_accept_required: false,
faq_url: "",
allow_restore: false,
maximum_backups: 5,
version_checks: true,
suppress_uncategorized_badge: true,
min_search_term_length: 3,
topic_views_heat_low: 1000,
topic_views_heat_medium: 2000,
topic_views_heat_high: 5000,
global_notice: "",
show_create_topics_notice: true,
available_locales:
"cs|da|de|en|es|fr|he|id|it|ja|ko|nb_NO|nl|pl_PL|pt|pt_BR|ru|sv|uk|zh_CN|zh_TW",
highlighted_languages:
"apache|bash|cs|cpp|css|coffeescript|diff|xml|http|ini|json|java|javascript|makefile|markdown|nginx|objectivec|ruby|perl|php|python|sql|handlebars",
enable_emoji: true,
enable_emoji_shortcuts: true,
emoji_set: "google_classic",
enable_emoji_shortcuts: true,
enable_inline_emoji_translation: false,
desktop_category_page_style: "categories_and_latest_topics",
enable_mentions: true,
enable_personal_messages: true,
personal_message_enabled_groups: "11", // TL1 group
unicode_usernames: false,
secure_uploads: false,
external_emoji_url: "",
remove_muted_tags_from_latest: "always",
enable_group_directory: true,
default_sidebar_categories: "",
default_sidebar_tags: "",
};
let siteSettings = Object.assign({}, ORIGINAL_SETTINGS);
// Note, CLIENT_SITE_SETTINGS_WITH_DEFAULTS is generated by the site-settings-plugin,
// writing to test-site-settings.js via the ember-cli-build pipeline.
const ORIGINAL_CLIENT_SITE_SETTINGS = Object.assign(
{},
// eslint-disable-next-line no-undef
CLIENT_SITE_SETTINGS_WITH_DEFAULTS,
CLIENT_SETTING_TEST_OVERRIDES
);
let siteSettings = Object.assign({}, ORIGINAL_CLIENT_SITE_SETTINGS);
export function currentSettings() {
return siteSettings;
@ -135,7 +51,8 @@ export function mergeSettings(other) {
export function resetSettings() {
for (let p in siteSettings) {
if (siteSettings.hasOwnProperty(p)) {
let v = ORIGINAL_SETTINGS[p];
// eslint-disable-next-line no-undef
let v = ORIGINAL_CLIENT_SITE_SETTINGS[p];
typeof v !== "undefined" ? setValue(p, v) : delete siteSettings[p];
}
}

View File

@ -37,6 +37,7 @@
</style>
<script src="{{rootURL}}assets/test-i18n.js"></script>
<script src="{{rootURL}}assets/test-site-settings.js"></script>
</head>
<body>
{{content-for "body"}} {{content-for "test-body"}}

View File

@ -126,9 +126,9 @@ module(
assert.strictEqual(this.subject.header().label(), "category…");
});
test("with allowUncategorized=null and generalCategoryId present", async function (assert) {
test("with allowUncategorized=null and defaultComposerCategory present", async function (assert) {
this.siteSettings.allow_uncategorized_topics = false;
this.siteSettings.general_category_id = 4;
this.siteSettings.default_composer_category = 4;
await render(hbs`
<CategoryChooser
@ -143,9 +143,9 @@ module(
assert.strictEqual(this.subject.header().label(), "");
});
test("with allowUncategorized=null and generalCategoryId present, but not set", async function (assert) {
test("with allowUncategorized=null and defaultComposerCategory present, but not set", async function (assert) {
this.siteSettings.allow_uncategorized_topics = false;
this.siteSettings.general_category_id = -1;
this.siteSettings.default_composer_category = -1;
await render(hbs`
<CategoryChooser

View File

@ -4,6 +4,7 @@ import { render } from "@ember/test-helpers";
import I18n from "I18n";
import { hbs } from "ember-cli-htmlbars";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { query } from "discourse/tests/helpers/qunit-helpers";
const DEFAULT_CONTENT = [
{ id: 1, name: "foo" },
@ -391,4 +392,23 @@ module("Integration | Component | select-kit/single-select", function (hooks) {
})
);
});
test("options.verticalOffset", async function (assert) {
setDefaultState(this, { verticalOffset: -50 });
await render(hbs`
<SingleSelect
@value={{this.value}}
@content={{this.content}}
@nameProperty={{this.nameProperty}}
@valueProperty={{this.valueProperty}}
@onChange={{this.onChange}}
@options={{hash verticalOffset=this.verticalOffset}}
/>
`);
await this.subject.expand();
const header = query(".select-kit-header").getBoundingClientRect();
const body = query(".select-kit-body").getBoundingClientRect();
assert.ok(header.bottom > body.top, "it correctly offsets the body");
});
});

View File

@ -96,6 +96,7 @@ module("Integration | Component | Widget | hamburger-menu", function (hooks) {
[...queryAll(".category-link .category-name")].map((el) => el.innerText),
this.site
.get("categoriesByCount")
.reject((c) => c.id === this.site.uncategorized_category_id)
.slice(0, 8)
.map((c) => c.name)
);
@ -103,7 +104,7 @@ module("Integration | Component | Widget | hamburger-menu", function (hooks) {
test("top categories - allow_uncategorized_topics", async function (assert) {
this.owner.unregister("service:current-user");
this.siteSettings.allow_uncategorized_topics = false;
this.siteSettings.allow_uncategorized_topics = true;
this.siteSettings.header_dropdown_category_count = 8;
await render(hbs`<MountWidget @widget="hamburger-menu" />`);
@ -113,7 +114,6 @@ module("Integration | Component | Widget | hamburger-menu", function (hooks) {
[...queryAll(".category-link .category-name")].map((el) => el.innerText),
this.site
.get("categoriesByCount")
.filter((c) => c.name !== "uncategorized")
.slice(0, 8)
.map((c) => c.name)
);
@ -122,7 +122,10 @@ module("Integration | Component | Widget | hamburger-menu", function (hooks) {
test("top categories", async function (assert) {
this.siteSettings.header_dropdown_category_count = 8;
maxCategoriesToDisplay = this.siteSettings.header_dropdown_category_count;
categoriesByCount = this.site.get("categoriesByCount").slice();
categoriesByCount = this.site
.get("categoriesByCount")
.reject((c) => c.id === this.site.uncategorized_category_id)
.slice();
categoriesByCount.every((c) => {
if (!topCategoryIds.includes(c.id)) {
if (mutedCategoryIds.length === 0) {

View File

@ -3,15 +3,11 @@ import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { count } from "discourse/tests/helpers/qunit-helpers";
import { hbs } from "ember-cli-htmlbars";
import Post from "discourse/models/post";
import { getOwner } from "discourse-common/lib/get-owner";
function postStreamTest(name, attrs) {
test(name, async function (assert) {
const site = this.container.lookup("service:site");
let posts = attrs.posts.call(this);
posts.forEach((p) => p.set("site", site));
this.set("posts", posts);
this.set("posts", attrs.posts.call(this));
await render(
hbs`<MountWidget @widget="post-stream" @args={{hash posts=this.posts}} />`
@ -26,13 +22,13 @@ module("Integration | Component | Widget | post-stream", function (hooks) {
postStreamTest("basics", {
posts() {
const site = this.container.lookup("service:site");
const site = getOwner(this).lookup("service:site");
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic");
topic.set("details.created_by", { id: 123 });
return [
Post.create({
store.createRecord("post", {
topic,
id: 1,
post_number: 1,
@ -40,27 +36,32 @@ module("Integration | Component | Widget | post-stream", function (hooks) {
primary_group_name: "trout",
avatar_template: "/images/avatar.png",
}),
Post.create({
store.createRecord("post", {
topic,
id: 2,
post_number: 2,
post_type: site.get("post_types.moderator_action"),
}),
Post.create({ topic, id: 3, post_number: 3, hidden: true }),
Post.create({
store.createRecord("post", {
topic,
id: 3,
post_number: 3,
hidden: true,
}),
store.createRecord("post", {
topic,
id: 4,
post_number: 4,
post_type: site.get("post_types.whisper"),
}),
Post.create({
store.createRecord("post", {
topic,
id: 5,
post_number: 5,
wiki: true,
via_email: true,
}),
Post.create({
store.createRecord("post", {
topic,
id: 6,
post_number: 6,
@ -133,7 +134,7 @@ module("Integration | Component | Widget | post-stream", function (hooks) {
topic.set("details.created_by", { id: 123 });
return [
Post.create({
store.createRecord("post", {
topic,
id: 1,
post_number: 1,

View File

@ -23,7 +23,6 @@ module("Integration | Component | Widget | poster-name", function (hooks) {
assert.ok(exists("span.username"));
assert.ok(exists('a[data-user-card="eviltrout"]'));
assert.strictEqual(query(".username a").innerText, "eviltrout");
assert.strictEqual(query(".full-name a").innerText, "Robin Ward");
assert.strictEqual(query(".user-title").innerText, "Trout Master");
});

View File

@ -47,8 +47,8 @@ module("Unit | Controller | create-account", function (hooks) {
controller.set("authProvider", "");
controller.set("accountEmail", "pork@chops.com");
controller.set("accountUsername", "porkchops");
controller.set("prefilledUsername", "porkchops");
controller.set("accountUsername", "porkchops123");
controller.set("prefilledUsername", "porkchops123");
controller.set("accountPassword", "b4fcdae11f9167");
assert.strictEqual(
@ -79,7 +79,10 @@ module("Unit | Controller | create-account", function (hooks) {
testInvalidPassword("", null);
testInvalidPassword("x", I18n.t("user.password.too_short"));
testInvalidPassword("porkchops", I18n.t("user.password.same_as_username"));
testInvalidPassword(
"porkchops123",
I18n.t("user.password.same_as_username")
);
testInvalidPassword(
"pork@chops.com",
I18n.t("user.password.same_as_email")

View File

@ -7,6 +7,7 @@ import { Placeholder } from "discourse/lib/posts-with-placeholders";
import User from "discourse/models/user";
import { next } from "@ember/runloop";
import { getOwner } from "discourse-common/lib/get-owner";
import sinon from "sinon";
function topicWithStream(streamDetails) {
const topic = this.store.createRecord("topic");
@ -78,6 +79,27 @@ module("Unit | Controller | topic", function (hooks) {
assert.ok(destroyed, "destroy not popular topic");
});
test("deleteTopic permanentDelete", function (assert) {
const opts = { force_destroy: true };
const model = this.store.createRecord("topic");
const siteSettings = this.owner.lookup("service:site-settings");
siteSettings.min_topic_views_for_delete_confirm = 5;
const controller = this.owner.lookup("controller:topic");
controller.setProperties({ model });
model.set("views", 100);
const stub = sinon.stub(model, "destroy");
controller.send("deleteTopic", { force_destroy: true });
assert.deepEqual(
stub.getCall(0).args[1],
opts,
"does not show delete confirm permanently deleting, passes opts to model action"
// permanent delete happens after first delete, no need to show modal again
);
});
test("toggleMultiSelect", async function (assert) {
const model = this.store.createRecord("topic");
const controller = getOwner(this).lookup("controller:topic");

Some files were not shown because too many files have changed in this diff Show More