Merge branch 'main' into feature/wizard-look-and-feel-improvements

This commit is contained in:
Martin Brennan 2024-12-09 10:01:35 +10:00
commit 19666eaa0a
No known key found for this signature in database
GPG Key ID: BD981EFEEC8F5675
171 changed files with 2544 additions and 1604 deletions

View File

@ -540,10 +540,10 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (2.4.0-aarch64-linux-gnu)
sqlite3 (2.4.0-arm64-darwin)
sqlite3 (2.4.0-x86_64-darwin)
sqlite3 (2.4.0-x86_64-linux-gnu)
sqlite3 (2.4.1-aarch64-linux-gnu)
sqlite3 (2.4.1-arm64-darwin)
sqlite3 (2.4.1-x86_64-darwin)
sqlite3 (2.4.1-x86_64-linux-gnu)
sshkey (3.0.0)
stackprof (0.2.26)
stringio (3.1.2)

View File

@ -205,7 +205,7 @@ export default class AdminFlagsForm extends Component {
</checkboxGroup.Field>
</form.CheckboxGroup>
<form.Alert @icon="info-circle">
<form.Alert @icon="circle-info">
{{i18n "admin.config_areas.flags.form.alert"}}
</form.Alert>

View File

@ -41,7 +41,7 @@ export default class ThemeCard extends Component {
}
get footerActionIcon() {
return this.args.theme.isPendingUpdates ? "sync" : "ellipsis-h";
return this.args.theme.isPendingUpdates ? "arrows-rotate" : "ellipsis";
}
// NOTE: inspired by -> https://github.com/discourse/discourse/blob/24caa36eef826bcdaed88aebfa7df154413fb349/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js#L366
@ -116,7 +116,7 @@ export default class ThemeCard extends Component {
<span
title={{i18n "admin.customize.theme.updates_available_tooltip"}}
class="theme-card__update-available"
>{{icon "info-circle"}}</span>
>{{icon "circle-info"}}</span>
{{/if}}
<div class="theme-card__image-wrapper">
{{#if @theme.screenshot_url}}
@ -179,7 +179,7 @@ export default class ThemeCard extends Component {
@preventFocus={{true}}
@icon={{if
@theme.default
"far-check-square"
"far-square-check"
"far-square"
}}
class="theme-card__button"

View File

@ -108,7 +108,7 @@
}}"
>
{{#if this.versionCheck.behindByOneVersion}}
{{d-icon "far-meh"}}
{{d-icon "far-face-meh"}}
{{else}}
{{d-icon "far-face-frown"}}
{{/if}}

View File

@ -14,13 +14,13 @@
<th>{{i18n "admin.api.user"}}</th>
<th>{{i18n "admin.api.created"}}</th>
<th>{{i18n "admin.api.last_used"}}</th>
<th>{{i18n "admin.site_settings.table_column_heading.status"}}</th>
<th>&nbsp;</th>
</thead>
<tbody>
{{#each this.model as |k|}}
<tr class="d-admin-row__content {{if k.revoked_at 'revoked'}}">
<td class="d-admin-row__overview key">
{{#if k.revoked_at}}{{d-icon "circle-xmark"}}{{/if}}
{{k.truncatedKey}}
</td>
<td class="d-admin-row__detail key-description">
@ -57,6 +57,20 @@
{{i18n "admin.api.never_used"}}
{{/if}}
</td>
<td class="d-admin-row__detail">
<div class="d-admin-row__mobile-label">{{i18n
"admin.site_settings.table_column_heading.status"
}}</div>
{{#if k.revoked_at}}
<div role="status" class="status-label">
<div class="status-label-indicator">
</div>
<div class="status-label-text">
{{i18n "admin.api.revoked"}}
</div>
</div>
{{/if}}
</td>
<td class="d-admin-row__controls key-controls">
<DButton
@action={{route-action "show" k}}

View File

@ -166,14 +166,9 @@
this.model.attributes.discourse_updated_at
leaveAgo="true"
}}</p>
<a
rel="noopener noreferrer"
target="_blank"
href={{this.model.attributes.release_notes_link}}
class="btn btn-default"
>
<LinkTo @route="admin.whatsNew" class="btn btn-default">
{{i18n "admin.dashboard.whats_new_in_discourse"}}
</a>
</LinkTo>
</div>
{{/if}}
</div>

View File

@ -10,7 +10,7 @@
{{#if this.model.can_view_action_logs}}
<DButton
@action={{fn this.viewActionLogs this.model.username}}
@icon="far-list-alt"
@icon="far-rectangle-list"
@label="admin.user.action_logs"
class="btn-default"
/>
@ -850,7 +850,7 @@
{{else}}
<DButton
@action={{fn this.checkSsoPayload this.model}}
@icon="far-list-alt"
@icon="far-rectangle-list"
@label="admin.users.check_sso.text"
@title="admin.users.check_sso.title"
class="btn-default"

View File

@ -7,7 +7,7 @@
<:breadcrumbs>
<DBreadcrumbsItem
@path="/admin/users/list"
@label={{i18n "admin.permalink.title"}}
@label={{i18n "admin.users.title"}}
/>
</:breadcrumbs>
<:actions as |actions|>

View File

@ -39,7 +39,7 @@
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.97.0"
"webpack": "^5.97.1"
},
"engines": {
"node": ">= 18",

View File

@ -18,7 +18,7 @@
},
"devDependencies": {
"clean-base-url": "^1.0.0",
"express": "^4.21.1",
"express": "^4.21.2",
"glob": "^10.4.3",
"html-entities": "^2.5.2",
"html-rewriter-wasm": "^0.4.1",

View File

@ -19,7 +19,7 @@
"@types/jquery": "^3.5.32",
"@types/qunit": "^2.19.12",
"@types/rsvp": "^4.0.9",
"webpack": "^5.97.0"
"webpack": "^5.97.1"
},
"engines": {
"node": ">= 18",

View File

@ -41,7 +41,7 @@
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.97.0"
"webpack": "^5.97.1"
},
"engines": {
"node": ">= 18",

View File

@ -19,7 +19,7 @@
},
"devDependencies": {
"ember-cli": "~6.0.1",
"webpack": "^5.97.0"
"webpack": "^5.97.1"
},
"engines": {
"node": ">= 18",

View File

@ -36,7 +36,7 @@
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.97.0"
"webpack": "^5.97.1"
},
"engines": {
"node": ">= 18",

View File

@ -0,0 +1,35 @@
import Component from "@glimmer/component";
import {
NO_REMINDER_ICON,
WITH_REMINDER_ICON,
} from "discourse/models/bookmark";
import icon from "discourse-common/helpers/d-icon";
import { i18n } from "discourse-i18n";
export default class BookmarkIcon extends Component {
get icon() {
if (this.args.bookmark?.get("reminder_at")) {
return WITH_REMINDER_ICON;
}
return NO_REMINDER_ICON;
}
get cssClasses() {
return this.args.bookmark
? "bookmark-icon bookmark-icon__bookmarked"
: "bookmark-icon";
}
get title() {
if (!this.args.bookmark) {
return i18n("bookmarks.create");
}
return this.args.bookmark.get("reminderTitle");
}
<template>
{{icon this.icon translatedTitle=this.title class=this.cssClasses}}
</template>
}

View File

@ -1 +0,0 @@
{{d-icon this.icon translatedTitle=this.title class=this.cssClasses}}

View File

@ -1,42 +0,0 @@
import Component from "@ember/component";
import { computed } from "@ember/object";
import { isEmpty } from "@ember/utils";
import {
NO_REMINDER_ICON,
WITH_REMINDER_ICON,
} from "discourse/models/bookmark";
import { i18n } from "discourse-i18n";
export default class BookmarkIcon extends Component {
tagName = "";
bookmark = null;
@computed("bookmark.reminder_at")
get icon() {
if (!this.bookmark) {
return NO_REMINDER_ICON;
}
if (!isEmpty(this.bookmark.reminder_at)) {
return WITH_REMINDER_ICON;
}
return NO_REMINDER_ICON;
}
@computed("bookmark")
get cssClasses() {
return this.bookmark
? "bookmark-icon bookmark-icon__bookmarked"
: "bookmark-icon";
}
@computed("bookmark.title")
get title() {
if (!this.bookmark) {
return i18n("bookmarks.create");
}
return this.bookmark.reminderTitle;
}
}

View File

@ -200,6 +200,7 @@ export default class ComposerEditor extends Component {
@bind
setupEditor(textManipulation) {
this.textManipulation = textManipulation;
this.uppyComposerUpload.textManipulation = textManipulation;
const input = this.element.querySelector(".d-editor-input");

View File

@ -306,8 +306,7 @@ export default class DEditor extends Component {
this.site.hashtag_configurations["topic-composer"],
this.siteSettings,
{
afterComplete: (value) => {
this.set("value", value);
afterComplete: () => {
schedule(
"afterRender",
this.textManipulation,
@ -327,8 +326,7 @@ export default class DEditor extends Component {
this.textManipulation.autocomplete({
template: findRawTemplate("emoji-selector-autocomplete"),
key: ":",
afterComplete: (text) => {
this.set("value", text);
afterComplete: () => {
schedule(
"afterRender",
this.textManipulation,
@ -466,9 +464,7 @@ export default class DEditor extends Component {
onRender: (options) => renderUserStatusHtml(options),
key: "@",
transformComplete: (v) => v.username || v.name,
afterComplete: (value) => {
this.set("value", value);
afterComplete: () => {
schedule(
"afterRender",
this.textManipulation,

View File

@ -62,7 +62,7 @@ export default class GroupCardContents extends CardContentsBase.extend(
this.setProperties({ group });
if (!group.flair_url && !group.flair_bg_color) {
group.set("flair_url", "fa-users");
group.set("flair_url", "users");
}
if (group.can_see_members && group.members.length < maxMembersToDisplay) {

View File

@ -131,6 +131,8 @@
{{/if}}
</div>
<PluginOutlet @name="move-to-topic-after-radio-buttons" />
{{#if this.existingTopic}}
<p>
{{html-safe

View File

@ -3,6 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { applyValueTransformer } from "discourse/lib/transformer";
import DiscourseURL from "discourse/lib/url";
import { mergeTopic, movePosts } from "discourse/models/topic";
import { i18n } from "discourse-i18n";
@ -145,6 +146,15 @@ export default class MoveToTopic extends Component {
};
}
mergeOptions = applyValueTransformer(
"move-to-topic-merge-options",
mergeOptions
);
moveOptions = applyValueTransformer(
"move-to-topic-move-options",
moveOptions
);
try {
let result;
if (this.args.model.selectedAllPosts) {

View File

@ -25,7 +25,7 @@ export default class PostMenuReadButton extends Component {
<DButton
...attributes
@action={{@buttonActions.toggleWhoRead}}
@icon="book-reader"
@icon="book-open-reader"
@title="post.controls.read_indicator"
/>
</div>

View File

@ -11,7 +11,7 @@ export default class PostMenuShowMoreButton extends Component {
class="post-action-menu__show-more show-more-actions"
...attributes
@action={{@buttonActions.showMoreActions}}
@icon="ellipsis-h"
@icon="ellipsis"
@title="show_more"
/>
</template>

View File

@ -185,6 +185,14 @@ export default class Item extends Component {
}
}
get useMobileLayout() {
return applyValueTransformer(
"topic-list-item-mobile-layout",
this.site.mobileView,
{ topic: this.args.topic }
);
}
<template>
<tr
{{! template-lint-disable no-invalid-interactive }}
@ -210,7 +218,7 @@ export default class Item extends Component {
(if @topic.closed "closed")
this.tagClassNames
(applyValueTransformer
"topic-list-item-class" (array) (hash topic=@topic)
"topic-list-item-class" (array) (hash topic=@topic index=@index)
)
}}
>
@ -218,20 +226,7 @@ export default class Item extends Component {
@name="above-topic-list-item"
@outletArgs={{hash topic=@topic}}
/>
{{#if this.site.desktopView}}
{{#each @columns as |entry|}}
<entry.value.item
@topic={{@topic}}
@bulkSelectEnabled={{@bulkSelectEnabled}}
@onBulkSelectToggle={{this.onBulkSelectToggle}}
@isSelected={{this.isSelected}}
@showTopicPostBadges={{@showTopicPostBadges}}
@hideCategory={{@hideCategory}}
@tagsForUser={{@tagsForUser}}
@expandPinned={{this.expandPinned}}
/>
{{/each}}
{{else}}
{{#if this.useMobileLayout}}
<td class="topic-list-data">
<div class="pull-left">
{{#if @bulkSelectEnabled}}
@ -337,6 +332,19 @@ export default class Item extends Component {
</div>
</div>
</td>
{{else}}
{{#each @columns as |entry|}}
<entry.value.item
@topic={{@topic}}
@bulkSelectEnabled={{@bulkSelectEnabled}}
@onBulkSelectToggle={{this.onBulkSelectToggle}}
@isSelected={{this.isSelected}}
@showTopicPostBadges={{@showTopicPostBadges}}
@hideCategory={{@hideCategory}}
@tagsForUser={{@tagsForUser}}
@expandPinned={{this.expandPinned}}
/>
{{/each}}
{{/if}}
</tr>
</template>

View File

@ -1,6 +1,6 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { array, hash } from "@ember/helper";
import { service } from "@ember/service";
import { eq, or } from "truth-helpers";
import PluginOutlet from "discourse/components/plugin-outlet";
@ -172,6 +172,7 @@ export default class TopicList extends Component {
class={{concatClass
"topic-list"
(if this.bulkSelectEnabled "sticky-header")
(applyValueTransformer "topic-list-class" (array) (hash topics=@topics))
}}
>
<caption class="sr-only">{{i18n "sr_topic_list_caption"}}</caption>

View File

@ -1,11 +1,21 @@
<div class="user-profile-avatar">
{{bound-avatar @user "huge"}}
<UserAvatarFlair @user={{@user}} />
<div>
<PluginOutlet
@name="user-profile-avatar-wrapper"
@outletArgs={{hash user=@user}}
>
<div class="user-profile-avatar">
<PluginOutlet
@name="user-profile-avatar-flair"
@connectorTagName="div"
@outletArgs={{hash model=@user}}
/>
@name="user-profile-avatar-img-wrapper"
@outletArgs={{hash user=@user}}
>
{{bound-avatar @user "huge"}}
</PluginOutlet>
<UserAvatarFlair @user={{@user}} />
<div>
<PluginOutlet
@name="user-profile-avatar-flair"
@outletArgs={{hash model=@user}}
/>
</div>
</div>
</div>
</PluginOutlet>

View File

@ -3,6 +3,7 @@ import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { NO_VALUE_OPTION } from "discourse/form-kit/lib/constants";
import { i18n } from "discourse-i18n";
import FKControlSelectOption from "./select/option";
export default class FKControlSelect extends Component {
@ -17,6 +18,10 @@ export default class FKControlSelect extends Component {
);
}
get hasSelectedValue() {
return this.args.field.value && this.args.field.value !== NO_VALUE_OPTION;
}
<template>
<select
value={{@field.value}}
@ -25,6 +30,14 @@ export default class FKControlSelect extends Component {
class="form-kit__control-select"
{{on "input" this.handleInput}}
>
<FKControlSelectOption @value={{NO_VALUE_OPTION}}>
{{#if this.hasSelectedValue}}
{{i18n "form_kit.select.none_placeholder"}}
{{else}}
{{i18n "form_kit.select.select_placeholder"}}
{{/if}}
</FKControlSelectOption>
{{yield
(hash Option=(component FKControlSelectOption selected=@field.value))
}}

View File

@ -2,7 +2,7 @@ import { cancel } from "@ember/runloop";
import { createPopper } from "@popperjs/core";
import $ from "jquery";
import { isDocumentRTL } from "discourse/lib/text-direction";
import { caretPosition, setCaretPosition } from "discourse/lib/utilities";
import { TextareaAutocompleteHandler } from "discourse/lib/textarea-text-manipulation";
import Site from "discourse/models/site";
import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
@ -114,6 +114,8 @@ export default function (options) {
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
let inputSelectedItems = [];
options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
function handlePaste() {
discourseLater(() => me.trigger("keydown"), 50);
}
@ -216,8 +218,6 @@ export default function (options) {
}
let completeTerm = async function (term, event) {
let completeEnd = null;
if (term) {
if (isInput) {
me.val("");
@ -231,15 +231,13 @@ export default function (options) {
}
if (term) {
let text = me.val();
// After completion is done our position for completeStart may have
// drifted. This can happen if the TEXTAREA changed out-of-band between
// the time autocomplete was first displayed and the time of completion
// Specifically this may happen due to uploads which inject a placeholder
// which is later replaced with a different length string.
let pos = await guessCompletePosition({ completeTerm: true });
let completeEnd = null;
if (
pos.completeStart !== undefined &&
pos.completeEnd !== undefined
@ -247,31 +245,18 @@ export default function (options) {
completeStart = pos.completeStart;
completeEnd = pos.completeEnd;
} else {
completeStart = completeEnd = caretPosition(me[0]);
completeStart = completeEnd =
options.textHandler.getCaretPosition();
}
let space =
text.substring(completeEnd + 1, completeEnd + 2) === " " ? "" : " ";
text =
text.substring(0, completeStart) +
(options.preserveKey ? options.key || "" : "") +
term +
space +
text.substring(completeEnd + 1, text.length);
me.val(text);
let newCaretPos = completeStart + 1 + term.length;
if (options.key) {
newCaretPos++;
}
setCaretPosition(me[0], newCaretPos);
options.textHandler.replaceTerm({
start: completeStart,
end: completeEnd,
term: (options.preserveKey ? options.key || "" : "") + term,
});
if (options && options.afterComplete) {
options.afterComplete(text, event);
options.afterComplete(options.textHandler.value, event);
}
}
}
@ -429,9 +414,7 @@ export default function (options) {
}
let vOffset = 0;
let pos = me.caretPosition({
pos: completeStart + 1,
});
let pos = options.textHandler.getCaretCoords(completeStart);
if (options.treatAsTextarea) {
vOffset = -32;
@ -539,7 +522,11 @@ export default function (options) {
closeAutocomplete();
});
async function checkTriggerRule(opts) {
async function checkTriggerRule(_opts) {
const opts = {
..._opts,
inCodeBlock: () => options.textHandler.inCodeBlock(),
};
const shouldTrigger = await options.triggerRule?.(me[0], opts);
return shouldTrigger ?? true;
}
@ -557,12 +544,12 @@ export default function (options) {
return true;
}
let cp = caretPosition(me[0]);
const key = me[0].value[cp - 1];
let cp = options.textHandler.getCaretPosition();
const key = options.textHandler.value[cp - 1];
if (options.key) {
if (options.onKeyUp && key !== options.key) {
let match = options.onKeyUp(me.val(), cp);
let match = options.onKeyUp(options.textHandler.value, cp);
if (match) {
completeStart = cp - match[0].length;
@ -574,10 +561,9 @@ export default function (options) {
if (completeStart === null && cp > 0) {
if (key === options.key) {
let prevChar = me.val().charAt(cp - 2);
const shouldTrigger = await checkTriggerRule();
let prevChar = options.textHandler.value.charAt(cp - 2);
if (
shouldTrigger &&
(await checkTriggerRule()) &&
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar))
) {
completeStart = cp - 1;
@ -585,7 +571,10 @@ export default function (options) {
}
}
} else if (completeStart !== null) {
let term = me.val().substring(completeStart + (options.key ? 1 : 0), cp);
let term = options.textHandler.value.substring(
completeStart + (options.key ? 1 : 0),
cp
);
updateAutoComplete(dataSource(term, options));
}
}
@ -593,10 +582,9 @@ export default function (options) {
async function guessCompletePosition(opts) {
let prev, stopFound, term;
let prevIsGood = true;
let element = me[0];
let backSpace = opts?.backSpace;
let completeTermOption = opts?.completeTerm;
let caretPos = caretPosition(element);
let caretPos = options.textHandler.getCaretPosition();
if (backSpace) {
caretPos -= 1;
@ -609,12 +597,12 @@ export default function (options) {
while (prevIsGood && caretPos >= 0) {
caretPos -= 1;
prev = element.value[caretPos];
prev = options.textHandler.value[caretPos];
stopFound = prev === options.key;
if (stopFound) {
prev = element.value[caretPos - 1];
prev = options.textHandler.value[caretPos - 1];
const shouldTrigger = await checkTriggerRule({ backSpace });
if (
@ -622,7 +610,10 @@ export default function (options) {
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
) {
start = caretPos;
term = element.value.substring(caretPos + 1, initialCaretPos);
term = options.textHandler.value.substring(
caretPos + 1,
initialCaretPos
);
end = caretPos + term.length;
break;
}
@ -653,9 +644,10 @@ export default function (options) {
inputSelectedItems.push("");
}
if (typeof inputSelectedItems[0] === "string" && me.val().length > 0) {
const value = options.textHandler.value;
if (typeof inputSelectedItems[0] === "string" && value.length > 0) {
inputSelectedItems.pop();
inputSelectedItems.push(me.val());
inputSelectedItems.push(value);
if (options.onChangeItems) {
options.onChangeItems(inputSelectedItems);
}
@ -693,10 +685,13 @@ export default function (options) {
}
if (completeStart !== null) {
cp = caretPosition(me[0]);
cp = options.textHandler.getCaretPosition();
// allow people to right arrow out of completion
if (e.which === keys.rightArrow && me[0].value[cp] === " ") {
if (
e.which === keys.rightArrow &&
options.textHandler.value[cp] === " "
) {
closeAutocomplete();
return true;
}
@ -771,7 +766,10 @@ export default function (options) {
return true;
}
term = me.val().substring(completeStart + (options.key ? 1 : 0), cp);
term = options.textHandler.value.substring(
completeStart + (options.key ? 1 : 0),
cp
);
if (completeStart === cp && term === options.key) {
closeAutocomplete();

View File

@ -4,11 +4,7 @@ import { ajax } from "discourse/lib/ajax";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
import { getHashtagTypeClasses as getHashtagTypeClassesNew } from "discourse/lib/hashtag-type-registry";
import { emojiUnescape } from "discourse/lib/text";
import {
caretPosition,
escapeExpression,
inCodeBlock,
} from "discourse/lib/utilities";
import { escapeExpression } from "discourse/lib/utilities";
import { INPUT_DELAY, isTesting } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
import discourseLater from "discourse-common/lib/later";
@ -50,8 +46,8 @@ export function setupHashtagAutocomplete(
);
}
export async function hashtagTriggerRule(textarea) {
return !(await inCodeBlock(textarea.value, caretPosition(textarea)));
export async function hashtagTriggerRule(textarea, { inCodeBlock }) {
return !(await inCodeBlock());
}
export function hashtagAutocompleteOptions(
@ -62,8 +58,6 @@ export function hashtagAutocompleteOptions(
return {
template: findRawTemplate("hashtag-autocomplete"),
key: "#",
afterComplete: autocompleteOptions.afterComplete,
treatAsTextarea: autocompleteOptions.treatAsTextarea,
scrollElementSelector: ".hashtag-autocomplete__fadeout",
autoSelectFirstSuggestion: true,
transformComplete: (obj) => obj.ref,
@ -75,6 +69,7 @@ export function hashtagAutocompleteOptions(
},
triggerRule: async (textarea, opts) =>
await hashtagTriggerRule(textarea, opts),
...autocompleteOptions,
};
}

View File

@ -11,14 +11,6 @@ export const ADMIN_NAV_MAP = [
label: "admin.account.sidebar_link.backups",
icon: "box-archive",
},
{
name: "admin_whats_new",
route: "admin.whatsNew",
label: "admin.account.sidebar_link.whats_new.title",
icon: "gift",
keywords: "admin.account.sidebar_link.whats_new.keywords",
moderator: true,
},
],
},
{

View File

@ -208,6 +208,14 @@ export function useAdminNavConfig(navMap) {
label: "admin.advanced.sidebar_link.all_site_settings",
icon: "gear",
},
{
name: "admin_whats_new",
route: "admin.whatsNew",
label: "admin.account.sidebar_link.whats_new.title",
icon: "gift",
keywords: "admin.account.sidebar_link.whats_new.keywords",
moderator: true,
},
],
},
];

View File

@ -12,9 +12,11 @@ import {
clipboardHelpers,
determinePostReplaceSelection,
inCodeBlock,
setCaretPosition,
} from "discourse/lib/utilities";
import { isTesting } from "discourse-common/config/environment";
import { bind } from "discourse-common/utils/decorators";
import escapeRegExp from "discourse-common/utils/escape-regexp";
import { i18n } from "discourse-i18n";
const INDENT_DIRECTION_LEFT = "left";
@ -51,13 +53,19 @@ export default class TextareaTextManipulation {
textarea;
$textarea;
autocompleteHandler;
placeholder;
constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) {
setOwner(this, owner);
this.placeholder = new TextareaPlaceholderHandler(owner, this);
this.eventPrefix = eventPrefix;
this.textarea = textarea;
this.$textarea = $(textarea);
this.autocompleteHandler = new TextareaAutocompleteHandler(textarea);
generateLinkifyFunction(markdownOptions || {}).then((linkify) => {
// When pasting links, we should use the same rules to match links as we do when creating links for a cooked post.
this._cachedLinkify = linkify;
@ -345,13 +353,7 @@ export default class TextareaTextManipulation {
}
_insertAt(start, end, text) {
this.textarea.setSelectionRange(start, end);
this.textarea.focus();
if (start !== end && text === "") {
document.execCommand("delete", false);
} else {
document.execCommand("insertText", false, text);
}
insertAtTextarea(this.textarea, start, end, text);
}
extractTable(text) {
@ -738,7 +740,6 @@ export default class TextareaTextManipulation {
}
}
@bind
async inCodeBlock() {
return inCodeBlock(
this.$textarea.value ?? this.$textarea.val(),
@ -825,7 +826,187 @@ export default class TextareaTextManipulation {
putCursorAtEnd(this.textarea);
}
autocomplete() {
return this.$textarea.autocomplete(...arguments);
autocomplete(options) {
return this.$textarea.autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
}
}
function insertAtTextarea(textarea, start, end, text) {
textarea.setSelectionRange(start, end);
textarea.focus();
if (start !== end && text === "") {
document.execCommand("delete", false);
} else {
document.execCommand("insertText", false, text);
}
}
export class TextareaAutocompleteHandler {
textarea;
$textarea;
constructor(textarea) {
this.textarea = textarea;
this.$textarea = $(textarea);
}
get value() {
return this.textarea.value;
}
replaceTerm({ start, end, term }) {
const space = this.value.substring(end + 1, end + 2) === " " ? "" : " ";
insertAtTextarea(this.textarea, start, end + 1, term + space);
setCaretPosition(this.textarea, start + 1 + term.trim().length);
}
getCaretPosition() {
return caretPosition(this.textarea);
}
getCaretCoords(start) {
return this.$textarea.caretPosition({ pos: start + 1 });
}
async inCodeBlock() {
return inCodeBlock(
this.$textarea.value ?? this.$textarea.val(),
caretPosition(this.$textarea)
);
}
}
class TextareaPlaceholderHandler {
@service composer;
textManipulation;
#placeholders = {};
constructor(owner, textManipulation) {
setOwner(this, owner);
this.textManipulation = textManipulation;
}
#uploadPlaceholder(file, currentMarkdown) {
const clipboard = i18n("clipboard");
const uploadFilenamePlaceholder = this.#uploadFilenamePlaceholder(
file,
currentMarkdown
);
const filename = uploadFilenamePlaceholder
? uploadFilenamePlaceholder
: clipboard;
let placeholder = `[${i18n("uploading_filename", { filename })}]()\n`;
if (!this.#cursorIsOnEmptyLine()) {
placeholder = `\n${placeholder}`;
}
return placeholder;
}
#cursorIsOnEmptyLine() {
const selectionStart = this.textManipulation.textarea.selectionStart;
return (
selectionStart === 0 ||
this.textManipulation.value.charAt(selectionStart - 1) === "\n"
);
}
#uploadFilenamePlaceholder(file, currentMarkdown) {
const filename = this.#filenamePlaceholder(file);
// when adding two separate files with the same filename search for matching
// placeholder already existing in the editor ie [Uploading: test.png…]
// and add order nr to the next one: [Uploading: test.png(1)…]
const escapedFilename = escapeRegExp(filename);
const regexString = `\\[${i18n("uploading_filename", {
filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?",
})}\\]\\(\\)`;
const globalRegex = new RegExp(regexString, "g");
const matchingPlaceholder = currentMarkdown.match(globalRegex);
if (matchingPlaceholder) {
// get last matching placeholder and its consecutive nr in regex
// capturing group and apply +1 to the placeholder
const lastMatch = matchingPlaceholder[matchingPlaceholder.length - 1];
const regex = new RegExp(regexString);
const orderNr = regex.exec(lastMatch)[1]
? parseInt(regex.exec(lastMatch)[1], 10) + 1
: 1;
return `${filename}(${orderNr})`;
}
return filename;
}
#filenamePlaceholder(data) {
return data.name.replace(/\u200B-\u200D\uFEFF]/g, "");
}
insert(file) {
const placeholder = this.#uploadPlaceholder(
file,
this.composer.model.reply
);
this.textManipulation.insertText(placeholder);
this.#placeholders[file.id] = { uploadPlaceholder: placeholder };
}
progress(file) {
let placeholderData = this.#placeholders[file.id];
placeholderData.processingPlaceholder = `[${i18n("processing_filename", {
filename: file.name,
})}]()\n`;
this.textManipulation.replaceText(
placeholderData.uploadPlaceholder,
placeholderData.processingPlaceholder
);
// Safari applies user-defined replacements to text inserted programmatically.
// One of the most common replacements is ... -> …, so we take care of the case
// where that transformation has been applied to the original placeholder
this.textManipulation.replaceText(
placeholderData.uploadPlaceholder.replace("...", "…"),
placeholderData.processingPlaceholder
);
}
progressComplete(file) {
let placeholderData = this.#placeholders[file.id];
this.textManipulation.replaceText(
placeholderData.processingPlaceholder,
placeholderData.uploadPlaceholder
);
}
cancelAll() {
Object.values(this.#placeholders).forEach((data) => {
this.textManipulation.replaceText(data.uploadPlaceholder, "");
});
}
cancel(file) {
if (this.#placeholders[file.id]) {
this.textManipulation.replaceText(
this.#placeholders[file.id].uploadPlaceholder,
""
);
}
}
success(file, markdown) {
this.textManipulation.replaceText(
this.#placeholders[file.id].uploadPlaceholder.trim(),
markdown
);
}
}

View File

@ -13,11 +13,15 @@ export const VALUE_TRANSFORMERS = Object.freeze([
"invite-simple-mode-topic",
"mentions-class",
"more-topics-tabs",
"move-to-topic-merge-options",
"move-to-topic-move-options",
"parent-category-row-class-mobile",
"parent-category-row-class",
"post-menu-buttons",
"small-user-attrs",
"topic-list-columns",
"topic-list-header-sortable-column",
"topic-list-class",
"topic-list-item-class",
"topic-list-item-mobile-layout",
]);

View File

@ -23,7 +23,6 @@ import UppyChecksum from "discourse/lib/uppy-checksum-plugin";
import { clipboardHelpers } from "discourse/lib/utilities";
import getURL from "discourse-common/lib/get-url";
import { bind } from "discourse-common/utils/decorators";
import escapeRegExp from "discourse-common/utils/escape-regexp";
import { i18n } from "discourse-i18n";
export default class UppyComposerUpload {
@ -53,12 +52,12 @@ export default class UppyComposerUpload {
uploadPreProcessors;
uploadHandlers;
textManipulation;
#inProgressUploads = [];
#bufferedUploadErrors = [];
#placeholders = {};
#consecutiveImages = [];
#useUploadPlaceholders = true;
#uploadTargetBound = false;
#userCancelled = false;
@ -288,7 +287,7 @@ export default class UppyComposerUpload {
);
file.meta.cancelled = true;
this.#removeInProgressUpload(file.id);
this.#resetUpload(file, { removePlaceholder: true });
this.#resetUpload(file);
if (this.#inProgressUploads.length === 0) {
this.#userCancelled = true;
this.uppyWrapper.uppyInstance.cancelAll();
@ -335,17 +334,7 @@ export default class UppyComposerUpload {
})
);
const placeholder = this.#uploadPlaceholder(file);
this.#placeholders[file.id] = {
uploadPlaceholder: placeholder,
};
if (this.#useUploadPlaceholders) {
this.appEvents.trigger(
`${this.composerEventPrefix}:insert-text`,
placeholder
);
}
this.textManipulation.placeholder.insert(file);
this.appEvents.trigger(
`${this.composerEventPrefix}:upload-started`,
@ -366,28 +355,22 @@ export default class UppyComposerUpload {
return;
}
let upload = response.body;
const markdown = await this.uploadMarkdownResolvers.reduce(
(md, resolver) => resolver(upload) || md,
getUploadMarkdown(upload)
);
// Only remove in progress after async resolvers finish:
this.#removeInProgressUpload(file.id);
cacheShortUploadUrl(upload.short_url, upload);
const markdown = await this.uploadMarkdownResolvers.reduce(
(md, resolver) => resolver(upload) || md,
getUploadMarkdown(upload)
);
new ComposerVideoThumbnailUppy(getOwner(this)).generateVideoThumbnail(
file,
upload.url,
() => {
if (this.#useUploadPlaceholders) {
this.appEvents.trigger(
`${this.composerEventPrefix}:replace-text`,
this.#placeholders[file.id].uploadPlaceholder.trim(),
markdown
);
}
this.textManipulation.placeholder.success(file, markdown);
this.#resetUpload(file, { removePlaceholder: false });
this.appEvents.trigger(
`${this.composerEventPrefix}:upload-success`,
file.name,
@ -412,18 +395,7 @@ export default class UppyComposerUpload {
this.uppyWrapper.uppyInstance.on("cancel-all", () => {
// Do the manual cancelling work only if the user clicked cancel
if (this.#userCancelled) {
Object.values(this.#placeholders).forEach((data) => {
run(() => {
if (this.#useUploadPlaceholders) {
this.appEvents.trigger(
`${this.composerEventPrefix}:replace-text`,
data.uploadPlaceholder,
""
);
}
});
});
this.textManipulation.placeholder.cancelAll();
this.#userCancelled = false;
this.#reset();
@ -442,7 +414,7 @@ export default class UppyComposerUpload {
@bind
_handleUploadError(file, error, response) {
this.#removeInProgressUpload(file.id);
this.#resetUpload(file, { removePlaceholder: true });
this.#resetUpload(file);
file.meta.error = error;
@ -508,36 +480,13 @@ export default class UppyComposerUpload {
});
this.uppyWrapper.onPreProcessProgress((file) => {
let placeholderData = this.#placeholders[file.id];
placeholderData.processingPlaceholder = `[${i18n("processing_filename", {
filename: file.name,
})}]()\n`;
this.appEvents.trigger(
`${this.composerEventPrefix}:replace-text`,
placeholderData.uploadPlaceholder,
placeholderData.processingPlaceholder
);
// Safari applies user-defined replacements to text inserted programmatically.
// One of the most common replacements is ... -> …, so we take care of the case
// where that transformation has been applied to the original placeholder
this.appEvents.trigger(
`${this.composerEventPrefix}:replace-text`,
placeholderData.uploadPlaceholder.replace("...", "…"),
placeholderData.processingPlaceholder
);
this.textManipulation.placeholder.progress(file);
});
this.uppyWrapper.onPreProcessComplete(
(file) => {
run(() => {
let placeholderData = this.#placeholders[file.id];
this.appEvents.trigger(
`${this.composerEventPrefix}:replace-text`,
placeholderData.processingPlaceholder,
placeholderData.uploadPlaceholder
);
this.textManipulation.placeholder.progressComplete(file);
});
},
() => {
@ -554,47 +503,6 @@ export default class UppyComposerUpload {
);
}
#uploadFilenamePlaceholder(file) {
const filename = this.#filenamePlaceholder(file);
// when adding two separate files with the same filename search for matching
// placeholder already existing in the editor ie [Uploading: test.png…]
// and add order nr to the next one: [Uploading: test.png(1)…]
const escapedFilename = escapeRegExp(filename);
const regexString = `\\[${i18n("uploading_filename", {
filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?",
})}\\]\\(\\)`;
const globalRegex = new RegExp(regexString, "g");
const matchingPlaceholder = this.composerModel.reply.match(globalRegex);
if (matchingPlaceholder) {
// get last matching placeholder and its consecutive nr in regex
// capturing group and apply +1 to the placeholder
const lastMatch = matchingPlaceholder[matchingPlaceholder.length - 1];
const regex = new RegExp(regexString);
const orderNr = regex.exec(lastMatch)[1]
? parseInt(regex.exec(lastMatch)[1], 10) + 1
: 1;
return `${filename}(${orderNr})`;
}
return filename;
}
#uploadPlaceholder(file) {
const clipboard = i18n("clipboard");
const uploadFilenamePlaceholder = this.#uploadFilenamePlaceholder(file);
const filename = uploadFilenamePlaceholder
? uploadFilenamePlaceholder
: clipboard;
let placeholder = `[${i18n("uploading_filename", { filename })}]()\n`;
if (!this.#cursorIsOnEmptyLine()) {
placeholder = `\n${placeholder}`;
}
return placeholder;
}
#useXHRUploads() {
this.uppyWrapper.uppyInstance.use(XHRUpload, {
endpoint: getURL(`/uploads.json?client_id=${this.messageBus.clientId}`),
@ -620,14 +528,8 @@ export default class UppyComposerUpload {
this.#fileInputEl.value = "";
}
#resetUpload(file, opts) {
if (opts.removePlaceholder && this.#placeholders[file.id]) {
this.appEvents.trigger(
`${this.composerEventPrefix}:replace-text`,
this.#placeholders[file.id].uploadPlaceholder,
""
);
}
#resetUpload(file) {
this.textManipulation.placeholder.cancel(file);
}
@bind
@ -704,10 +606,6 @@ export default class UppyComposerUpload {
);
}
#filenamePlaceholder(data) {
return data.name.replace(/\u200B-\u200D\uFEFF]/g, "");
}
#findMatchingUploadHandler(fileName) {
return this.uploadHandlers.find((handler) => {
const ext = handler.extensions.join("|");
@ -716,14 +614,6 @@ export default class UppyComposerUpload {
});
}
#cursorIsOnEmptyLine() {
const textArea = this.#editorEl.querySelector(this.editorInputClass);
const selectionStart = textArea.selectionStart;
return (
selectionStart === 0 || textArea.value.charAt(selectionStart - 1) === "\n"
);
}
#autoGridImages() {
const reply = this.composerModel.get("reply");
const imagesToWrapGrid = new Set(this.#consecutiveImages);

View File

@ -23,6 +23,7 @@ import discourseComputed from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
let _customizations = [];
export function registerCustomizationCallback(cb) {
_customizations.push(cb);
}
@ -804,29 +805,29 @@ export default class Composer extends RestModel {
}
/**
Open a composer
Open a composer
@method open
@param {Object} opts
@param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage
@param {String} opts.draftKey
@param {String} opts.draftSequence
@param {Post} [opts.post] The post we're replying to, if present
@param {Topic} [opts.topic] The topic we're replying to, if present
@param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making
@param {String} [opts.reply]
@param {String} [opts.recipients]
@param {Number} [opts.composerTime]
@param {Number} [opts.typingTime]
@param {Boolean} [opts.whisper]
@param {Boolean} [opts.noBump]
@param {String} [opts.archetypeId] One of `site.archetypes` e.g. `regular` or `private_message`
@param {Object} [opts.metaData]
@param {Number} [opts.categoryId]
@param {Number} [opts.postId]
@param {Number} [opts.destinationCategoryId]
@param {String} [opts.title]
**/
@method open
@param {Object} opts
@param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage
@param {String} opts.draftKey
@param {String} opts.draftSequence
@param {Post} [opts.post] The post we're replying to, if present
@param {Topic} [opts.topic] The topic we're replying to, if present
@param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making
@param {String} [opts.reply]
@param {String} [opts.recipients]
@param {Number} [opts.composerTime]
@param {Number} [opts.typingTime]
@param {Boolean} [opts.whisper]
@param {Boolean} [opts.noBump]
@param {String} [opts.archetypeId] One of `site.archetypes` e.g. `regular` or `private_message`
@param {Object} [opts.metaData]
@param {Number} [opts.categoryId]
@param {Number} [opts.postId]
@param {Number} [opts.destinationCategoryId]
@param {String} [opts.title]
**/
open(opts) {
let promise = Promise.resolve();
@ -886,7 +887,18 @@ export default class Composer extends RestModel {
});
if (!this.topic) {
this.set("topic", opts.post.topic);
if (opts.post.topic) {
this.set("topic", opts.post.topic);
} else {
// handles the edge cases where the topic model is not loaded in the post model and the store does not have a
// topic for the post, e.g., make a post then edit right away, edit a post outside the post stream, etc.
promise = promise.then(async () => {
const data = await Topic.find(opts.post.topic_id, {});
const topic = this.store.createRecord("topic", data);
this.post.set("topic", topic);
this.set("topic", topic);
});
}
}
} else if (opts.postId) {
promise = promise.then(() =>
@ -935,37 +947,22 @@ export default class Composer extends RestModel {
}
this.setProperties(topicProps);
promise = promise.then(() => {
let rawPromise = this.store.find("post", opts.post.id).then((post) => {
this.setProperties({
post,
reply: post.raw,
originalText: post.raw,
});
if (post.post_number === 1 && this.canEditTitle) {
this.setProperties({
originalTitle: post.topic.title,
originalTags: post.topic.tags,
});
}
promise = promise.then(async () => {
const post = await this.store.find("post", opts.post.id);
this.setProperties({
post,
reply: post.raw,
originalText: post.raw,
});
// edge case ... make a post then edit right away
// store does not have topic for the post
if (this.topic && this.topic.id === this.post.topic_id) {
// nothing to do ... we have the right topic
} else {
rawPromise = this.store
.find("topic", this.post.topic_id)
.then((topic) => {
this.set("topic", topic);
});
if (post.post_number === 1 && this.canEditTitle) {
this.setProperties({
originalTitle: this.topic.title,
originalTags: this.topic.tags,
});
}
return rawPromise.then(() => {
this.appEvents.trigger("composer:reply-reloaded", this);
});
this.appEvents.trigger("composer:reply-reloaded", this);
});
} else if (opts.action === REPLY && opts.quote) {
this.set("reply", opts.quote);

View File

@ -171,14 +171,13 @@ export default class Post extends RestModel {
@alias("can_edit") canEdit; // for compatibility with existing code
@equal("trust_level", 0) new_user;
@equal("post_number", 1) firstPost;
@or("deleted_at", "deletedViaTopic") deleted;
@and("firstPost", "topic.deleted_at") deletedViaTopic; // mark fist post as deleted if topic was deleted
@or("deleted_at", "deletedViaTopic") deleted; // post is either highlighted as deleted or hidden/removed from the post stream
@not("deleted") notDeleted;
@or("deleted_at", "user_deleted") recoverable; // post or content still can be recovered
@propertyEqual("topic.details.created_by.id", "user_id") topicOwner;
@alias("topic.details.created_by.id") topicCreatedById;
// Posts can show up as deleted if the topic is deleted
@and("firstPost", "topic.deleted_at") deletedViaTopic;
constructor() {
super(...arguments);
@ -314,14 +313,6 @@ export default class Post extends RestModel {
return this.firstPost && !!this.topic.details.can_publish_page;
}
get canRecover() {
return this.deleted && this.can_recover;
}
get isRecovering() {
return !this.deleted && this.can_recover;
}
get canRecoverTopic() {
return this.firstPost && this.deleted && this.topic.details.can_recover;
}
@ -330,6 +321,14 @@ export default class Post extends RestModel {
return this.firstPost && !this.deleted && this.topic.details.can_recover;
}
get canRecover() {
return !this.canRecoverTopic && this.recoverable && this.can_recover;
}
get isRecovering() {
return !this.isRecoveringTopic && !this.recoverable && this.can_recover;
}
get canToggleLike() {
return !!this.likeAction?.get("canToggle");
}

View File

@ -19,6 +19,7 @@ import ActionSummary from "discourse/models/action-summary";
import Bookmark from "discourse/models/bookmark";
import RestModel from "discourse/models/rest";
import Site from "discourse/models/site";
import TopicDetails from "discourse/models/topic-details";
import { flushMap } from "discourse/services/store";
import deprecated from "discourse-common/lib/deprecated";
import getURL from "discourse-common/lib/get-url";
@ -461,7 +462,13 @@ export default class Topic extends RestModel {
}
set details(value) {
this._details = value;
if (value instanceof TopicDetails) {
this._details = value;
return;
}
// we need to ensure that details is an instance of TopicDetails
this._details = this.store.createRecord("topicDetails", value);
}
@discourseComputed("visible")

View File

@ -41,7 +41,7 @@
</div>
{{/if}}
{{#if this.model.automatic}}
{{#if (and this.currentUser.admin this.model.automatic)}}
<DTooltip class="admin-group-automatic-tooltip">
<:trigger>
{{d-icon "gear"}}

View File

@ -36,7 +36,7 @@
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/standalone": "^7.26.2",
"@babel/standalone": "^7.26.4",
"@colors/colors": "^1.6.0",
"@discourse/backburner.js": "^2.7.1-0",
"@discourse/itsatrap": "^2.0.10",
@ -46,7 +46,7 @@
"@ember/render-modifiers": "^2.1.0",
"@ember/string": "^4.0.0",
"@ember/test-helpers": "^4.0.4",
"@ember/test-waiters": "^3.1.0",
"@ember/test-waiters": "^4.0.0",
"@embroider/compat": "^3.7.0",
"@embroider/core": "^3.4.19",
"@embroider/macros": "^1.16.9",
@ -112,18 +112,18 @@
"make-plural": "^7.4.0",
"message-bus-client": "^4.3.8",
"pretender": "^3.4.7",
"qunit": "^2.23.0",
"qunit": "^2.23.1",
"qunit-dom": "^3.4.0",
"sass": "^1.77.7",
"select-kit": "workspace:1.0.0",
"sinon": "^19.0.2",
"source-map": "^0.7.4",
"terser": "^5.36.0",
"terser": "^5.37.0",
"testem": "^3.15.2",
"truth-helpers": "workspace:1.0.0",
"util": "^0.12.5",
"virtual-dom": "^2.1.1",
"webpack": "^5.97.0",
"webpack": "^5.97.1",
"webpack-retry-chunk-load-plugin": "^3.1.1",
"webpack-stats-plugin": "^1.1.3",
"xss": "^1.0.15"

View File

@ -215,7 +215,7 @@ acceptance(
visible: true,
public_admission: true,
public_exit: false,
flair_url: "fa-circle-half-stroke",
flair_url: "circle-half-stroke",
is_group_owner: true,
mentionable: true,
messageable: true,

View File

@ -24,6 +24,24 @@ acceptance("Managing Group Membership", function (needs) {
);
});
test("As an admin on an automatic group", async function (assert) {
await visit("/g/moderators");
assert
.dom(".admin-group-automatic-tooltip")
.exists("displays automatic tooltip");
});
test("As a non-admin user on an automatic group", async function (assert) {
updateCurrentUser({ admin: false });
await visit("/g/moderators");
assert
.dom(".admin-group-automatic-tooltip")
.doesNotExist("does not display automatic tooltip");
});
test("As an admin", async function (assert) {
updateCurrentUser({ can_create_group: true });

View File

@ -0,0 +1,49 @@
import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { withPluginApi } from "discourse/lib/plugin-api";
import pretender, {
parsePostData,
response,
} from "discourse/tests/helpers/create-pretender";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
acceptance("Modal - move-to-topic", function (needs) {
needs.user({ admin: true });
test("Transformer can modify merge/move options sent in request", async function (assert) {
withPluginApi("1.24.0", (api) => {
["move-to-topic-merge-options", "move-to-topic-move-options"].forEach(
(transformerName) => {
api.registerValueTransformer(transformerName, (transformer) => {
transformer.value.sillyVal = true;
return transformer.value;
});
}
);
});
await visit("/t/internationalization-localization/280");
// Open admin menu, select a post, and open move to topic modal
await click(".topic-admin-menu-trigger");
await click(".topic-admin-menu-content .topic-admin-multi-select button");
await click(".select-posts .select-post");
await click(".selected-posts .move-to-topic");
// Choose existing topic, and pick the first topic.
await click("input#move-to-existing-topic");
await fillIn("input#choose-topic-title", 1);
await click(".choose-topic-list .existing-topic input");
pretender.post("/t/280/move-posts", (request) => {
assert.step("request");
const data = parsePostData(request.requestBody);
assert.strictEqual(data.sillyVal, "true");
return response({ success: true });
});
// Submit!
await click(".d-modal__footer .btn-primary");
assert.verifySteps(["request"]);
});
});

View File

@ -70,7 +70,7 @@ acceptance("Search - Full Page", function (needs) {
grant_count: 0,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
image: null,
listable: true,
enabled: true,

View File

@ -60,7 +60,7 @@ export default {
grant_count: 11,
allow_title: true,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 8,
@ -75,7 +75,7 @@ export default {
grant_count: 10,
allow_title: true,
multiple_grant: false,
icon: "fa-gear",
icon: "gear",
listable: true,
enabled: true,
badge_grouping_id: 8,
@ -89,7 +89,7 @@ export default {
grant_count: 29,
allow_title: true,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 4,
@ -103,7 +103,7 @@ export default {
grant_count: 200,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 8,
@ -118,7 +118,7 @@ export default {
grant_count: 9,
allow_title: true,
multiple_grant: false,
icon: "fa-bug",
icon: "bug",
listable: true,
enabled: true,
badge_grouping_id: 7,
@ -132,7 +132,7 @@ export default {
grant_count: 0,
allow_title: true,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 8,
@ -146,7 +146,7 @@ export default {
grant_count: 467,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 4,
@ -161,7 +161,7 @@ export default {
grant_count: 183,
allow_title: false,
multiple_grant: false,
icon: "fa-bug",
icon: "bug",
listable: true,
enabled: true,
badge_grouping_id: 7,
@ -175,7 +175,7 @@ export default {
grant_count: 4,
allow_title: true,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 4,
@ -189,7 +189,7 @@ export default {
grant_count: 278,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -203,7 +203,7 @@ export default {
grant_count: 5834,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 4,
@ -217,7 +217,7 @@ export default {
grant_count: 60,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -231,7 +231,7 @@ export default {
grant_count: 22,
allow_title: false,
multiple_grant: true,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 3,
@ -245,7 +245,7 @@ export default {
grant_count: 2,
allow_title: false,
multiple_grant: true,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 3,
@ -259,7 +259,7 @@ export default {
grant_count: 2387,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -273,7 +273,7 @@ export default {
grant_count: 285,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -287,7 +287,7 @@ export default {
grant_count: 42,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 2,
@ -301,7 +301,7 @@ export default {
grant_count: 1718,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 2,
@ -315,7 +315,7 @@ export default {
grant_count: 270,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -330,7 +330,7 @@ export default {
grant_count: 545,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -344,7 +344,7 @@ export default {
grant_count: 397,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -358,7 +358,7 @@ export default {
grant_count: 259,
allow_title: false,
multiple_grant: true,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 3,
@ -372,7 +372,7 @@ export default {
grant_count: 933,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 2,
@ -396,7 +396,7 @@ export default {
grant_count: 545,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -419,7 +419,7 @@ export default {
grant_count: 10,
allow_title: true,
multiple_grant: true,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -437,7 +437,7 @@ export default {
grant_count: 545,
allow_title: false,
multiple_grant: false,
icon: "fa-certificate",
icon: "certificate",
listable: true,
enabled: true,
badge_grouping_id: 1,
@ -1714,7 +1714,7 @@ export default {
{
id: 1,
name: "Only icon",
icon: "fa-rocket",
icon: "rocket",
},
{
id: 2,
@ -1724,7 +1724,7 @@ export default {
{
id: 3,
name: "Both image and icon",
icon: "fa-rocket",
icon: "rocket",
image_url: "/images/avatar.png?3",
multiple_grant: true,
query: "SELECT 1",

View File

@ -42,7 +42,7 @@ export default {
visible: true,
public_admission: true,
public_exit: false,
flair_url: "fa-circle-half-stroke",
flair_url: "circle-half-stroke",
is_group_owner: true,
mentionable: true,
messageable: true,
@ -66,7 +66,7 @@ export default {
visible: true,
public_admission: true,
public_exit: false,
flair_url: "fa-circle-half-stroke",
flair_url: "circle-half-stroke",
is_group_owner: true,
mentionable: true,
messageable: true,
@ -260,7 +260,7 @@ export default {
},
},
"/groups/discourse/posts.json": {
"posts": [
posts: [
{
id: 94607,
cooked:
@ -1319,8 +1319,8 @@ export default {
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
}
]
},
],
},
"/groups/alternative-group.json": {
group: {
@ -1334,7 +1334,7 @@ export default {
has_messages: true,
public_admission: true,
public_exit: false,
flair_url: "fa-circle-half-stroke",
flair_url: "circle-half-stroke",
is_group_owner: true,
mentionable: true,
messageable: true,

View File

@ -49,7 +49,7 @@ export default {
grant_count: 14,
allow_title: false,
multiple_grant: true,
icon: "fa-certificate",
icon: "certificate",
image: null,
listable: true,
enabled: true,
@ -349,7 +349,7 @@ export default {
grant_count: 14,
allow_title: false,
multiple_grant: true,
icon: "fa-certificate",
icon: "certificate",
image: null,
listable: true,
enabled: true,
@ -3210,7 +3210,7 @@ export default {
grant_count: 14,
allow_title: false,
multiple_grant: true,
icon: "fa-certificate",
icon: "certificate",
image: null,
listable: true,
enabled: true,

View File

@ -1,9 +1,8 @@
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import BookmarkIcon from "discourse/components/bookmark-icon";
import { formattedReminderTime } from "discourse/lib/bookmark";
import { tomorrow } from "discourse/lib/time-utils";
import Bookmark from "discourse/models/bookmark";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { i18n } from "discourse-i18n";
@ -11,15 +10,14 @@ module("Integration | Component | bookmark-icon", function (hooks) {
setupRenderingTest(hooks);
test("with reminder", async function (assert) {
this.setProperties({
bookmark: Bookmark.create({
reminder_at: tomorrow(this.currentUser.user_option.timezone),
name: "some name",
currentUser: this.currentUser,
}),
const store = this.owner.lookup("service:store");
const bookmark = store.createRecord("bookmark", {
reminder_at: tomorrow(this.currentUser.user_option.timezone),
name: "some name",
currentUser: this.currentUser,
});
await render(hbs`<BookmarkIcon @bookmark={{this.bookmark}} />`);
await render(<template><BookmarkIcon @bookmark={{bookmark}} /></template>);
assert
.dom(".d-icon-discourse-bookmark-clock.bookmark-icon__bookmarked")
@ -28,7 +26,7 @@ module("Integration | Component | bookmark-icon", function (hooks) {
"title",
i18n("bookmarks.created_with_reminder_generic", {
date: formattedReminderTime(
this.bookmark.reminder_at,
bookmark.reminder_at,
this.currentUser.user_option.timezone
),
name: "some name",
@ -37,15 +35,13 @@ module("Integration | Component | bookmark-icon", function (hooks) {
});
test("no reminder", async function (assert) {
this.set(
"bookmark",
Bookmark.create({
name: "some name",
currentUser: this.currentUser,
})
);
const store = this.owner.lookup("service:store");
const bookmark = store.createRecord("bookmark", {
name: "some name",
currentUser: this.currentUser,
});
await render(hbs`<BookmarkIcon @bookmark={{this.bookmark}} />`);
await render(<template><BookmarkIcon @bookmark={{bookmark}} /></template>);
assert.dom(".d-icon-bookmark.bookmark-icon__bookmarked").exists();
assert.dom(".svg-icon-title").hasAttribute(
@ -56,12 +52,8 @@ module("Integration | Component | bookmark-icon", function (hooks) {
);
});
test("null bookmark", async function (assert) {
this.setProperties({
bookmark: null,
});
await render(hbs`<BookmarkIcon @bookmark={{this.bookmark}} />`);
test("no bookmark", async function (assert) {
await render(<template><BookmarkIcon /></template>);
assert.dom(".d-icon-bookmark.bookmark-icon").exists();
assert

View File

@ -166,7 +166,7 @@ module("Integration | Component | CreateInvite", function (hooks) {
assert.deepEqual(
formKit().field("expiresAfterDays").options(),
["1", "3", "7", "30", "90", "999999"],
["__NONE__", "1", "3", "7", "30", "90", "999999"],
"the value of invite_expiry_days is added to the dropdown"
);
@ -179,7 +179,7 @@ module("Integration | Component | CreateInvite", function (hooks) {
assert.deepEqual(
formKit().field("expiresAfterDays").options(),
["1", "7", "30", "90", "999999"],
["__NONE__", "1", "7", "30", "90", "999999"],
"the value of invite_expiry_days is not added to the dropdown if it's already one of the options"
);
});

View File

@ -3,6 +3,7 @@ import { module, test } from "qunit";
import Form from "discourse/components/form";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import formKit from "discourse/tests/helpers/form-kit-helper";
import { i18n } from "discourse-i18n";
module(
"Integration | Component | FormKit | Controls | Select",
@ -50,5 +51,33 @@ module(
assert.dom(".form-kit__control-select").hasAttribute("disabled");
});
test("no selection", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Select as |select|>
<select.Option @value="option-1">Option 1</select.Option>
</field.Select>
</form.Field>
</Form>
</template>);
assert
.dom(".form-kit__control-select option:nth-child(1)")
.hasText(
i18n("form_kit.select.select_placeholder"),
"it shows a placeholder for selection"
);
await formKit().field("foo").select("option-1");
assert
.dom(".form-kit__control-select option:nth-child(1)")
.hasText(
i18n("form_kit.select.none_placeholder"),
"it shows a placeholder for unselection"
);
});
}
);

View File

@ -9,28 +9,28 @@ function setupSiteGroups(that) {
{
id: 1,
name: "admins",
flair_url: "fa-bars",
flair_url: "bars",
flair_bg_color: "CC000A",
flair_color: "FFFFFA",
},
{
id: 2,
name: "staff",
flair_url: "fa-bars",
flair_url: "bars",
flair_bg_color: "CC0005",
flair_color: "FFFFF5",
},
{
id: 3,
name: "trust_level_1",
flair_url: "fa-dice-one",
flair_url: "dice-one",
flair_bg_color: "CC0001",
flair_color: "FFFFF1",
},
{
id: 4,
name: "trust_level_2",
flair_url: "fa-dice-two",
flair_url: "dice-two",
flair_bg_color: "CC0002",
flair_color: "FFFFF2",
},
@ -159,7 +159,7 @@ module("Integration | Component | user-avatar-flair", function (hooks) {
moderator: false,
trust_level: 3,
flair_name: "Band Geeks",
flair_url: "fa-xmark",
flair_url: "xmark",
flair_bg_color: "123456",
flair_color: "B0B0B0",
flair_group_id: 41,

View File

@ -8,7 +8,7 @@ module("Integration | Component | Widget | avatar-flair", function (hooks) {
test("avatar flair with an icon", async function (assert) {
this.set("args", {
flair_url: "fa-bars",
flair_url: "bars",
flair_bg_color: "CC0000",
flair_color: "FFFFFF",
});

View File

@ -151,4 +151,22 @@ module("Unit | Utility | autocomplete", function (hooks) {
assert.dom("#ac-testing li a.selected").exists({ count: 1 });
assert.dom("#ac-testing li a.selected").hasText("test1");
});
test("Autocomplete doesn't reset undo history", async function (assert) {
const element = textArea();
$(element).autocomplete({
key: "@",
template,
dataSource: () => ["test1", "test2"],
});
await simulateKeys(element, "@t\r");
assert.strictEqual(element.value, "@test1 ");
document.execCommand("undo");
assert.strictEqual(element.value, "@t");
});
});

View File

@ -4,6 +4,7 @@ import { IMAGE_VERSION as v } from "pretty-text/emoji/version";
import { module, test } from "qunit";
import Category from "discourse/models/category";
import Topic from "discourse/models/topic";
import TopicDetails from "discourse/models/topic-details";
module("Unit | Model | topic", function (hooks) {
setupTest(hooks);
@ -108,6 +109,10 @@ module("Unit | Model | topic", function (hooks) {
const topic = this.store.createRecord("topic", { id: 1234 });
const topicDetails = topic.details;
assert.true(
topicDetails instanceof TopicDetails,
"topicDetails is an instance of TopicDetails"
);
assert.present(topicDetails, "a topic has topicDetails after we create it");
assert.strictEqual(
topicDetails.topic,
@ -165,6 +170,10 @@ module("Unit | Model | topic", function (hooks) {
});
assert.blank(topic.post_stream, "it does not update post_stream");
assert.true(
topic.details instanceof TopicDetails,
"topicDetails is an instance of TopicDetails"
);
assert.strictEqual(topic.details.hello, "world", "it updates the details");
assert.strictEqual(topic.cool, "property", "it updates other properties");
assert.strictEqual(topic.category, category);

View File

@ -39,7 +39,7 @@
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.97.0"
"webpack": "^5.97.1"
},
"engines": {
"node": ">= 18",

View File

@ -40,7 +40,7 @@
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.97.0"
"webpack": "^5.97.1"
},
"engines": {
"node": ">= 18",

View File

@ -1,5 +1,10 @@
{{#if this.collection.content.length}}
<ul class="select-kit-collection" aria-live="polite" role="menu">
<ul
class="select-kit-collection"
aria-live="polite"
role="menu"
{{this.bodyScrollLock}}
>
{{#each this.collection.content as |item index|}}
{{component
(component-for-row this.collection.identifier item this.selectKit)

View File

@ -1,5 +1,35 @@
import Component from "@ember/component";
import { service } from "@ember/service";
import { tagName } from "@ember-decorators/component";
import { modifier } from "ember-modifier";
import {
disableBodyScroll,
enableBodyScroll,
} from "discourse/lib/body-scroll-lock";
@tagName("")
export default class SelectKitCollection extends Component {}
export default class SelectKitCollection extends Component {
@service site;
bodyScrollLock = modifier((element) => {
if (!this.site.mobileView) {
return;
}
// when opened a modal will disable all scroll but itself
// this code is whitelisting the collection to ensure it can be scrolled in this case
// however we only want to do this if the modal is open to avoid breaking the scroll on the page
// eg: opening a combobox under a topic shouldn't prevent you to scroll the topic page
const isModalOpen =
document.documentElement.classList.contains("modal-open");
if (!isModalOpen) {
return;
}
disableBodyScroll(element);
return () => {
enableBodyScroll(element);
};
});
}

View File

@ -40,7 +40,7 @@
"ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "^5.97.0"
"webpack": "^5.97.1"
},
"engines": {
"node": ">= 18",

View File

@ -7,7 +7,7 @@
"license": "GPL-2.0-only",
"keywords": [],
"dependencies": {
"@babel/standalone": "^7.26.2",
"@babel/standalone": "^7.26.4",
"@zxing/text-encoding": "^0.9.0",
"babel-plugin-ember-template-compilation": "^2.3.0",
"content-tag": "^3.0.0",
@ -20,7 +20,7 @@
"handlebars": "^4.7.8",
"path-browserify": "^1.0.1",
"polyfill-crypto.getrandomvalues": "^1.0.0",
"terser": "^5.36.0"
"terser": "^5.37.0"
},
"engines": {
"node": ">= 18",

View File

@ -66,6 +66,33 @@
}
}
}
.status-label {
--d-border-radius: var(--space-4);
display: flex;
flex-wrap: nowrap;
width: fit-content;
background-color: var(--primary-low);
padding: var(--space-1) var(--space-2);
border-radius: var(--d-border-radius);
.status-label-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--primary-high);
flex-shrink: 0;
margin-right: var(--space-1);
margin-top: 0.4rem;
}
.status-label-text {
color: var(--primary-high);
font-size: var(--font-down-1);
}
}
}
.d-admin-row__overview {

View File

@ -45,15 +45,15 @@ table.web-hooks.grid {
// Api keys
table.api-keys {
.d-admin-table.api-keys {
margin-bottom: 0.25em;
tr.revoked {
color: var(--primary-medium);
color: var(--primary-high);
}
.d-admin-row__overview.key {
width: 30%;
width: 20%;
}
}

View File

@ -63,6 +63,10 @@
padding-left: calc(
48px - var(--pm-padding)
); // 48px is the width of the avatar
display: grid;
grid-template-areas: "contents additional" "pm-map pm-map";
grid-template-columns: 1fr auto;
section {
border: none;
background: var(--primary-very-low);
@ -73,8 +77,22 @@
padding-top: var(--pm-padding);
}
&__contents,
&__additional-contents {
padding-top: var(--pm-padding);
}
&__contents {
grid-area: contents;
}
&__additional-contents {
grid-area: additional;
}
&__private-message-map {
padding-bottom: var(--pm-padding);
grid-area: pm-map;
padding: 0.5em var(--pm-padding) var(--pm-padding);
}
.participants {

View File

@ -47,7 +47,7 @@
}
}
}
&.--updating .d-icon-sync {
&.--updating .d-icon-arrows-rotate {
animation: rotate 3s linear infinite;
margin-right: 0.45em;
@keyframes rotate {

View File

@ -857,6 +857,7 @@ class TopicsController < ApplicationController
params.permit(:participants)
params.permit(:chronological_order)
params.permit(:archetype)
params.permit(:freeze_original)
raise Discourse::InvalidAccess if params[:archetype] == "private_message" && !guardian.is_staff?
@ -869,6 +870,7 @@ class TopicsController < ApplicationController
args = {}
args[:destination_topic_id] = destination_topic_id.to_i
args[:chronological_order] = params[:chronological_order] == "true"
args[:freeze_original] = params[:freeze_original] == "true"
if params[:archetype].present?
args[:archetype] = params[:archetype]
@ -891,6 +893,7 @@ class TopicsController < ApplicationController
params.permit(:participants)
params.permit(:chronological_order)
params.permit(:archetype)
params.permit(:freeze_original)
topic = Topic.with_deleted.find_by(id: topic_id)
guardian.ensure_can_move_posts!(topic)
@ -1399,6 +1402,7 @@ class TopicsController < ApplicationController
].present?
args[:tags] = params[:tags] if params[:tags].present?
args[:chronological_order] = params[:chronological_order] == "true"
args[:freeze_original] = true if params[:freeze_original] == "true"
if params[:archetype].present?
args[:archetype] = params[:archetype]

View File

@ -252,7 +252,7 @@ class Badge < ActiveRecord::Base
def default_icon=(val)
if self.image_upload_id.blank?
self.icon ||= val
self.icon = val if self.icon == "fa-certificate"
self.icon = val if self.icon == "certificate"
end
end
@ -353,7 +353,7 @@ end
# updated_at :datetime not null
# allow_title :boolean default(FALSE), not null
# multiple_grant :boolean default(FALSE), not null
# icon :string default("fa-certificate")
# icon :string default("certificate")
# listable :boolean default(TRUE)
# target_posts :boolean default(FALSE)
# query :text
@ -363,9 +363,9 @@ end
# trigger :integer
# show_posts :boolean default(FALSE), not null
# system :boolean default(FALSE), not null
# show_in_post_header :boolean default(FALSE), not null
# long_description :text
# image_upload_id :integer
# show_in_post_header :boolean default(FALSE), not null
#
# Indexes
#

View File

@ -6,6 +6,12 @@ class MovedPost < ActiveRecord::Base
belongs_to :new_topic, class_name: "Topic", foreign_key: :new_topic_id
belongs_to :new_post, class_name: "Post", foreign_key: :new_post_id
# The author of the moved post
belongs_to :posting_user, class_name: "User", foreign_key: :post_user_id
# The user who moved the post
belongs_to :user
end
# == Schema Information
@ -23,12 +29,16 @@ end
# created_new_topic :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# old_topic_title :string
# post_user_id :integer
# user_id :integer
#
# Indexes
#
# index_moved_posts_on_new_post_id (new_post_id)
# index_moved_posts_on_new_topic_id (new_topic_id)
# index_moved_posts_on_old_post_id (old_post_id)
# index_moved_posts_on_old_post_number (old_post_number)
# index_moved_posts_on_old_topic_id (old_topic_id)
# index_moved_posts_on_new_post_id (new_post_id)
# index_moved_posts_on_new_topic_id (new_topic_id)
# index_moved_posts_on_new_topic_id_and_post_user_id (new_topic_id,post_user_id)
# index_moved_posts_on_old_post_id (old_post_id)
# index_moved_posts_on_old_post_number (old_post_number)
# index_moved_posts_on_old_topic_id (old_topic_id)
#

View File

@ -11,8 +11,12 @@ class PostMover
# freeze_original: :boolean - if true, the original topic will be frozen but not deleted and posts will be "copied" to topic
def initialize(original_topic, user, post_ids, move_to_pm: false, options: {})
@original_topic = original_topic
@original_topic_title = original_topic.title
@user = user
@post_ids = post_ids
# For now we store a copy of post_ids. If `freeze_original` is present, we will have new post_ids.
# When we create the new posts, we will pluck out post_ids out of this and replace with updated ids.
@post_ids_after_move = post_ids
@move_to_pm = move_to_pm
@options = options
end
@ -274,6 +278,13 @@ class PostMover
new_post.custom_fields = post.custom_fields
new_post.save_custom_fields
# When freezing original, ensure the notification generated points
# to the newly created post, not the old OP
if @options[:freeze_original]
@post_ids_after_move =
@post_ids_after_move.map { |post_id| post_id == post.id ? new_post.id : post_id }
end
DiscourseEvent.trigger(:first_post_moved, new_post, post)
DiscourseEvent.trigger(:post_moved, new_post, original_topic.id)
@ -307,6 +318,11 @@ class PostMover
moved_post.disable_rate_limits! if @options[:freeze_original]
moved_post.save(validate: false)
if moved_post.id != post.id
@post_ids_after_move =
@post_ids_after_move.map { |post_id| post_id == post.id ? moved_post.id : post_id }
end
DiscourseEvent.trigger(:post_moved, moved_post, original_topic.id)
# Move any links from the post to the new topic
@ -337,6 +353,7 @@ class PostMover
old_topic_id: post.topic_id,
old_post_id: post.id,
old_post_number: post.post_number,
post_user_id: post.user_id,
new_topic_id: destination_topic.id,
new_post_number: new_post_number,
new_topic_title: destination_topic.title,
@ -347,10 +364,12 @@ class PostMover
metadata[:new_post_id] = new_post.id
metadata[:now] = Time.zone.now
metadata[:created_new_topic] = @creating_new_topic
metadata[:old_topic_title] = @original_topic_title
metadata[:user_id] = @user.id
DB.exec(<<~SQL, metadata)
INSERT INTO moved_posts(old_topic_id, old_post_id, old_post_number, new_topic_id, new_topic_title, new_post_id, new_post_number, created_new_topic, created_at, updated_at)
VALUES (:old_topic_id, :old_post_id, :old_post_number, :new_topic_id, :new_topic_title, :new_post_id, :new_post_number, :created_new_topic, :now, :now)
INSERT INTO moved_posts(old_topic_id, old_topic_title, old_post_id, old_post_number, post_user_id, user_id, new_topic_id, new_topic_title, new_post_id, new_post_number, created_new_topic, created_at, updated_at)
VALUES (:old_topic_id, :old_topic_title, :old_post_id, :old_post_number, :post_user_id, :user_id, :new_topic_id, :new_topic_title, :new_post_id, :new_post_number, :created_new_topic, :now, :now)
SQL
end
@ -703,7 +722,7 @@ class PostMover
def enqueue_jobs(topic)
@post_creator.enqueue_jobs if @post_creator
Jobs.enqueue(:notify_moved_posts, post_ids: post_ids, moved_by_id: user.id)
Jobs.enqueue(:notify_moved_posts, post_ids: @post_ids_after_move, moved_by_id: user.id)
Jobs.enqueue(:delete_inaccessible_notifications, topic_id: topic.id)
end

View File

@ -270,8 +270,15 @@ class Reviewable < ActiveRecord::Base
def actions_for(guardian, args = nil)
args ||= {}
built_actions =
Actions.new(self, guardian).tap { |actions| build_actions(actions, guardian, args) }
Actions.new(self, guardian).tap { |actions| build_actions(actions, guardian, args) }
# Empty bundles can cause big issues on the client side, so we remove them
# here. It's not valid anyway to have a bundle with no actions, but you can
# add a bundle via actions.add_bundle and then not add any actions to it.
built_actions.bundles.reject!(&:empty?)
built_actions
end
def editable_for(guardian, args = nil)

View File

@ -1298,6 +1298,9 @@ class Topic < ActiveRecord::Base
moved_by,
post_ids,
move_to_pm: opts[:archetype].present? && opts[:archetype] == "private_message",
options: {
freeze_original: opts[:freeze_original],
},
)
if opts[:destination_topic_id]

View File

@ -10,6 +10,6 @@
<%= t 'admin_login.safe_mode' %>
</label>
<br/>
<%= submit_tag t('admin_login.submit_button'), class: "btn btn-primary" %>
<%= button_tag t('admin_login.submit_button'), class: "btn btn-primary", type: "submit" %>
<% end %>
<% end %>

View File

@ -5,7 +5,7 @@
<form method="post">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<%= button_tag(type: "submit", class: "btn btn-primary") do %>
<%= SvgSprite.raw_svg('fa-plug') %><%= t 'login.omniauth_confirm_button' %>
<%= SvgSprite.raw_svg('plug') %><%= t 'login.omniauth_confirm_button' %>
<% end %>
</form>
</div>

View File

@ -2479,6 +2479,9 @@ ar:
optional: اختياري
errors_summary_title: "يحتوي هذا النموذج على أخطاء:"
dirty_form: "لم ترسل التغييرات! هل تريد بالتأكيد المغادرة؟"
select:
select_placeholder: "تحديد…"
none_placeholder: "لا يوجد"
errors:
required: "مطلوب"
invalid_url: "يجب أن يكون عنوان URL صالحًا"
@ -7731,6 +7734,8 @@ ar:
title: "تحميل الصور"
selectable_avatars:
title: "قائمة الصور الرمزية التي يمكن للمستخدمين الاختيار منها"
table_column_heading:
status: "الحالة"
categories:
all_results: "الكل"
required: "مطلوب"

View File

@ -2161,6 +2161,8 @@ be:
label: "Загрузіць"
selectable_avatars:
title: "Спіс аватараў карыстальнікі могуць выбраць"
table_column_heading:
status: "Статус"
categories:
all_results: "усе"
required: "абавязковыя"

View File

@ -1767,6 +1767,8 @@ bg:
form_kit:
reset: Нулиране
optional: по избор
select:
none_placeholder: "Без"
errors:
required: "Задъжителни"
close: "Затвори"
@ -4285,6 +4287,8 @@ bg:
uploaded_image_list:
upload:
label: "Качване"
table_column_heading:
status: "Статус"
categories:
all_results: "Всички"
required: "Задъжителни"

View File

@ -1443,6 +1443,8 @@ bs_BA:
form_kit:
reset: Resetovati
optional: opciono
select:
none_placeholder: "Ništa"
errors:
required: "Required"
close: "Zatvori"
@ -4310,6 +4312,8 @@ bs_BA:
label: "Učitavanje"
selectable_avatars:
title: "Popis avatara koje korisnici mogu izabrati"
table_column_heading:
status: "Status"
categories:
all_results: "All"
required: "Required"

View File

@ -1389,6 +1389,8 @@ ca:
form_kit:
reset: Restableix
optional: opcional
select:
none_placeholder: "Cap"
errors:
required: "Necessari"
close: "Tanca"
@ -4166,6 +4168,8 @@ ca:
label: "Carrega"
selectable_avatars:
title: "Llista d'avatars que els usuaris poden triar"
table_column_heading:
status: "Estat"
categories:
all_results: "Tot"
required: "Necessari"

View File

@ -2164,6 +2164,9 @@ cs:
form_kit:
reset: obnovit výchozí
optional: volitelné
select:
select_placeholder: "Vybrat…"
none_placeholder: "Žádná"
errors:
required: "Nezbytnosti"
too_low: "Musí být alespoň %{count}"
@ -6453,6 +6456,8 @@ cs:
uploaded_image_list:
upload:
label: "Nahrát"
table_column_heading:
status: "Stav"
categories:
all_results: "Všechny"
required: "Nezbytnosti"

View File

@ -1694,6 +1694,9 @@ da:
form_kit:
reset: Nulstil
optional: valgfri
select:
select_placeholder: "Vælg…"
none_placeholder: "Ingen"
errors:
required: "Obligatoriske"
invalid_url: "Skal være en gyldig URL"
@ -5280,6 +5283,8 @@ da:
label: "Overfør"
selectable_avatars:
title: "Liste over avatarer, som brugere kan vælge imellem"
table_column_heading:
status: "Status"
categories:
all_results: "Alle"
required: "Obligatoriske"

View File

@ -2051,6 +2051,9 @@ de:
optional: optional
errors_summary_title: "Dieses Formular enthält Fehler:"
dirty_form: "Du hast deine Änderungen nicht gespeichert! Bist du sicher, dass du die Seite verlassen möchtest?"
select:
select_placeholder: "Auswählen …"
none_placeholder: "Keiner"
errors:
required: "Erforderlich"
invalid_url: "Muss eine gültige URL sein"
@ -6687,6 +6690,8 @@ de:
title: "Bilder hochladen"
selectable_avatars:
title: "Liste von Avataren, aus der Benutzer wählen können."
table_column_heading:
status: "Status"
categories:
all_results: "Alle"
required: "Erforderlich"

View File

@ -1747,6 +1747,9 @@ el:
optional: προεραιτικό
errors_summary_title: "Αυτή η φόρμα περιέχει σφάλματα:"
dirty_form: "Δεν έχετε υποβάλει τις αλλαγές σας! Είστε σίγουροι ότι θέλετε να αποχωρήσετε;"
select:
select_placeholder: "Επιλογή…"
none_placeholder: "Κανένα"
errors:
required: "Απαιτείται"
invalid_url: "Πρέπει να είναι μια έγκυρη διεύθυνση URL"
@ -5155,6 +5158,8 @@ el:
label: "Μεταφόρτωση"
selectable_avatars:
title: "Λίστα των avatars που μπορούν να επιλέξουν οι χρήστες"
table_column_heading:
status: "Κατάσταση"
categories:
all_results: "Όλα"
required: "Απαιτείται"

View File

@ -2203,6 +2203,9 @@ en:
optional: optional
errors_summary_title: "This form contains errors:"
dirty_form: "You didn't submit your changes! Are you sure you want to leave?"
select:
select_placeholder: "Select…"
none_placeholder: "None"
errors:
required: "Required"
invalid_url: "Must be a valid URL"
@ -7095,6 +7098,8 @@ en:
title: "Upload images"
selectable_avatars:
title: "List of avatars users can choose from"
table_column_heading:
status: "Status"
categories:
all_results: "All"
required: "Required"

View File

@ -2050,6 +2050,9 @@ es:
optional: opcional
errors_summary_title: "Este formulario contiene errores:"
dirty_form: "¡No has enviado tus cambios! ¿Seguro que quieres salir?"
select:
select_placeholder: "Seleccionar…"
none_placeholder: "Ninguno"
errors:
required: "Obligatorio"
invalid_url: "Debe ser una URL válida"
@ -6642,6 +6645,8 @@ es:
title: "Subir imágenes"
selectable_avatars:
title: "Lista de avatares que los usuarios pueden escoger"
table_column_heading:
status: "Estado"
categories:
all_results: "Todo"
required: "Obligatorio"

View File

@ -1253,6 +1253,9 @@ et:
form_kit:
reset: Lähtesta
optional: valikuline
select:
select_placeholder: "Vali..."
none_placeholder: "Pole"
errors:
required: "Nõutud"
close: "Sulge"
@ -3620,6 +3623,8 @@ et:
uploaded_image_list:
upload:
label: "Lae üles"
table_column_heading:
status: "Staatus"
categories:
all_results: "Kõik"
required: "Nõutud"

View File

@ -1681,6 +1681,9 @@ fa_IR:
form_kit:
reset: بازنشانی
optional: اختیاری
select:
select_placeholder: "انتخاب…"
none_placeholder: "هیچ کدام"
errors:
required: "مورد نیاز"
close: "بستن"
@ -4685,6 +4688,8 @@ fa_IR:
label: "ویرایش فهرست"
upload:
label: "بارگذاری"
table_column_heading:
status: "وضعیت"
categories:
all_results: "همه"
required: "مورد نیاز"

View File

@ -2050,6 +2050,9 @@ fi:
optional: valinnainen
errors_summary_title: "Tämä lomake sisältää virheitä:"
dirty_form: "Et lähettänyt muutoksiasi! Oletko varma, että haluat poistua?"
select:
select_placeholder: "Valitse…"
none_placeholder: "Ei valittu"
errors:
required: "Pakollinen"
invalid_url: "Täytyy olla kelvollinen URL"
@ -6643,6 +6646,8 @@ fi:
title: "Lataa kuvia"
selectable_avatars:
title: "Avatarit, joista käyttäjä voi valita"
table_column_heading:
status: "Tila"
categories:
all_results: "Kaikki"
required: "Pakolliset"

View File

@ -2050,6 +2050,9 @@ fr:
optional: facultatif
errors_summary_title: "Ce formulaire contient des erreurs :"
dirty_form: "Vous n'avez pas soumis vos modifications ! Voulez-vous vraiment partir ?"
select:
select_placeholder: "Sélectionnez…"
none_placeholder: "Jamais"
errors:
required: "Requis"
invalid_url: "Doit être une URL valide"
@ -6643,6 +6646,8 @@ fr:
title: "Téléverser des images"
selectable_avatars:
title: "Liste d'avatars que les utilisateurs peuvent choisir"
table_column_heading:
status: "Statut"
categories:
all_results: "Toutes"
required: "Requis"

View File

@ -1534,6 +1534,8 @@ gl:
form_kit:
reset: Restabelecer
optional: opcional
select:
none_placeholder: "Ningunha"
errors:
required: "Obrigatorio"
close: "Pechar"
@ -4733,6 +4735,8 @@ gl:
label: "Cargar"
selectable_avatars:
title: "Listaxe de avatares que os usuarios poden elixir"
table_column_heading:
status: "Estado"
categories:
all_results: "Todo"
required: "Obrigatorio"

View File

@ -2275,6 +2275,9 @@ he:
optional: רשות
errors_summary_title: "הטופס הזה מכיל שגיאות:"
dirty_form: "לא הגשת את השינויים שלך! לצאת?"
select:
select_placeholder: "בחירה…"
none_placeholder: "ללא"
errors:
required: "נדרש"
invalid_url: "חייבת להיות כתובת תקפה"
@ -3954,6 +3957,7 @@ he:
other: "מוצגות %{count} תגובות אל"
in_reply_to: "טעינת הפוסט ההורה"
view_all_posts: "הצגת כל הפוסטים"
badge_granted_tooltip: "ל־%{username} יש זכאות לעיטור %{badge_name} על הפוסט!"
errors:
create: "אירעה שגיאה ביצירת הפוסט שלך. נא לנסות שוב, עמך הסליחה."
edit: "אירעה שגיאה בעריכת הפוסט שלך. נא לנסות שוב, עמך הסליחה."
@ -7248,6 +7252,8 @@ he:
title: "העלאת תמונות"
selectable_avatars:
title: "רשימת תמונות ייצוגיות מהן המשתמשים יכולים לבחור"
table_column_heading:
status: "מצב"
categories:
all_results: "הכול"
required: "נדרש"
@ -7351,10 +7357,12 @@ he:
no_user_badges: "למשתמש %{name} לא הוענקו עיטורים."
no_badges: אין עיטורים שניתן להעניק.
none_selected: "נא לבחור עיטור כדי להתחיל"
usage_heading: שימוש
allow_title: לאפשר להשתמש בעיטור ככותרת
multiple_grant: יכולים להינתן מספר פעמים
visibility_heading: חשיפה
listable: הצגת עיטורים בעמוד העיטורים הפומבי
show_in_post_header: הצגת עיטור על הפוסט שבגינו הוענק
enabled: פעיל
disabled: מושבת
icon: סמליל

View File

@ -1923,6 +1923,9 @@ hr:
form_kit:
reset: Resetirati
optional: neobvezno
select:
select_placeholder: "Odaberite…"
none_placeholder: "Ništa"
errors:
required: "Potrebno"
close: "Zatvori"
@ -5885,6 +5888,8 @@ hr:
label: "Učitaj"
selectable_avatars:
title: "Popis avatara koji korisnici mogu birati"
table_column_heading:
status: "Statust"
categories:
all_results: "Sve"
required: "Potrebno"

View File

@ -2051,6 +2051,9 @@ hu:
optional: nem kötelező
errors_summary_title: "Ez az űrlap hibákat tartalmaz:"
dirty_form: "A változások nem lettek mentve! Biztos hogy el akarja hagyni a területet?"
select:
select_placeholder: "Kiválaszt..."
none_placeholder: "Egyik sem"
errors:
required: "Kötelező"
invalid_url: "Érvényes URL-nek kell lennie"
@ -6655,6 +6658,8 @@ hu:
title: "Kép(ek) feltöltése"
selectable_avatars:
title: "A felhasználó által választható profilképek listája"
table_column_heading:
status: "Állapot"
categories:
all_results: "Mind"
required: "Kötelező"

View File

@ -1976,6 +1976,9 @@ hy:
form_kit:
reset: Զրոյացնել
optional: ընտրովի
select:
select_placeholder: "Ընտրել..."
none_placeholder: "Ոչ մի"
errors:
required: "Պարտադիր"
too_short:
@ -2675,6 +2678,7 @@ hy:
read_more_in_category: "Ցանկանո՞ւմ եք կարդալ ավելին: Դիտեք այլ թեմաներ՝ %{categoryLink}-ում կամ <a href='%{latestLink}'>դիտեք վերջին թեմաները</a>"
read_more: "Ցանկանու՞մ եք կարդալ ավելին: <a href='%{categoryLink}'>Դիտեք բոլոր կատեգորիաները</a> կամ <a href='%{latestLink}'>դիտեք վերջին թեմաները</a>:"
unread_indicator: "Ոչ մի անդամ դեռ չի կարդացել այս թեմայի մինչև վերջին գրառումը: "
created_at: "Ստեղծվել է՝ %{date}"
bumped_at: "Վերջինը՝ %{date}"
browse_all_categories_latest: "<a href='%{basePath}/categories'>Թերթեք բոլոր կատեգորիաները</a> կամ <a href='%{basePath}/latest'>դիտեք վերջին թեմաները</a>."
browse_all_categories_latest_or_top: "<a href='%{basePath}/categories'>Թերթեք բոլոր կատեգորիաները</a>, <a href='%{basePath}/latest'>դիտեք վերջին թեմաները</a> կամ ամենահայտնիները:"
@ -2935,7 +2939,7 @@ hy:
publishing_settings: "Հրապարակման Կարգավորումներ"
change_owner:
title: "Փոխել Սեփականատիրոջը"
action: "Փոխել Սեփականատիրոջը"
action: "փոխել սեփականության իրավունքը"
error: "Գրառումների սեփականատիրոջը փոփոխելիս տեղի է ունեցել սխալ:"
placeholder: "նոր սեփականատիրոջ օգտանունը"
instructions:
@ -3061,6 +3065,7 @@ hy:
rebake: "Վերակառուցել HTML-ը"
publish_page: "Էջի Հրատարակում"
unhide: "Դարձնել Տեսանելի"
change_owner: "Փոխել Սեփականատիրոջը..."
lock_post: "Արգելափակել Գրառումը"
lock_post_description: "արգելել հրապարակողին խմբագրել այս գրառումը"
unlock_post: "Արգելաբացել Գրառումը"
@ -4025,8 +4030,19 @@ hy:
groups_filter_instructions: "Համապատասխան webhook-ները կգործարկվեն միայն, եթե իրադարձությունը կապված է ընտրված խմբերի հետ: Թողեք դատարկ՝ webhook-ները բոլոր խմբերի համար գործարկելու համար:"
groups_filter: "Գործարկվող Խմբերը"
delete_confirm: "Ջնջե՞լ այս webhook-ը:"
topic_event:
topic_created: "Թեման ստեղծված է"
post_event:
post_created: "Գրառումը ստեղծված է"
group_event:
group_created: "Խումբը ստեղծված է"
tag_event:
tag_created: "Պիտակը ստեղծված է"
category_event:
category_created: "Կատեգորիան ստեղծված է"
user_event:
user_confirmed_email: "Օգտագործողը հաստատել է էլ. փոստը"
user_created: "Օգտագործողը ստեղծված է"
delivery_status:
title: "Ուղարկման Կարգավիճակը"
inactive: "Ոչ ակտիվ"
@ -5042,6 +5058,8 @@ hy:
label: "Վերբեռնել"
selectable_avatars:
title: "Անձնապատկերների ցանկը, որոնցից օգտատերերը կարող են ընտրություն կատարել"
table_column_heading:
status: "Ստատուս"
categories:
all_results: "Ամբողջը"
required: "Պարտադիր"
@ -5162,6 +5180,7 @@ hy:
uploading: "Վերբեռնվում է…"
name: "Անուն"
group: "Խումբ"
created_by: "Ստեղծել է՝"
image: "Նկար"
alt: "մասնավոր էմոջիի նախնական դիտում "
delete_confirm: "Դուք համոզվա՞ծ եք, որ ցանկանում եք ջնջել :%{name}: էմոջին:"

View File

@ -1453,6 +1453,8 @@ id:
form_kit:
reset: Reset
optional: opsional
select:
none_placeholder: "Tidak ada"
close: "Tutup"
assets_changed_confirm: "Situs ini baru saja menerima pembaruan perangkat lunak. Dapatkan versi terbaru sekarang?"
logout: "Anda telah keluar."
@ -2777,6 +2779,8 @@ id:
uploaded_image_list:
upload:
label: "Unggah"
table_column_heading:
status: "Status"
categories:
all_results: "Semua"
users: "Pengguna"

View File

@ -2050,6 +2050,9 @@ it:
optional: facoltativo
errors_summary_title: "Questo modulo contiene errori:"
dirty_form: "Non hai caricato le modifiche! Vuoi davvero uscire?"
select:
select_placeholder: "Seleziona…"
none_placeholder: "Nessuna"
errors:
required: "Obbligatorio"
invalid_url: "L'URL deve essere valido"
@ -6644,6 +6647,8 @@ it:
title: "Carica immagini"
selectable_avatars:
title: "Elenco degli Avatar tra cui l'utente può scegliere"
table_column_heading:
status: "Stato"
categories:
all_results: "Tutte"
required: "Obbligatorie"

View File

@ -1939,6 +1939,9 @@ ja:
optional: オプション
errors_summary_title: "このフォームにはエラーがあります:"
dirty_form: "変更を送信しませんでした!終了してもよろしいですか?"
select:
select_placeholder: "選択…"
none_placeholder: "なし"
errors:
required: "必須"
invalid_url: "有効な URL である必要があります"
@ -6366,6 +6369,8 @@ ja:
title: "画像のアップロード"
selectable_avatars:
title: "ユーザーが選択できるアバターのリスト"
table_column_heading:
status: "ステータス"
categories:
all_results: "すべて"
required: "必須"

View File

@ -1613,6 +1613,8 @@ ko:
form_kit:
reset: 리셋
optional: 선택 사항
select:
none_placeholder: "없음"
errors:
required: "필수"
too_short:
@ -5009,6 +5011,8 @@ ko:
label: "업로드"
selectable_avatars:
title: "사용자가 선택할 수있는 아바타 목록"
table_column_heading:
status: "상태"
categories:
all_results: "전체"
required: "필수"

View File

@ -1676,6 +1676,8 @@ lt:
form_kit:
reset: Atstatyti
optional: pasirinktinai
select:
none_placeholder: "Nieko"
errors:
required: "Privalomi"
close: "Uždaryti"
@ -4926,6 +4928,8 @@ lt:
label: "Įkelti"
selectable_avatars:
title: "Avatarų, kuriuos vartotojai gali pasirinkti, sąrašas"
table_column_heading:
status: "Statusas"
categories:
all_results: "Visi"
required: "Privalomi"

View File

@ -1462,6 +1462,8 @@ lv:
form_kit:
reset: Atlikt
optional: pēc izvēles
select:
none_placeholder: "Nav"
errors:
required: "Nepieciešams"
close: "Aizvērt"
@ -3677,6 +3679,8 @@ lv:
uploaded_image_list:
upload:
label: "Augšupielādēt"
table_column_heading:
status: "Statuss"
categories:
all_results: "Viss"
required: "Nepieciešams"

View File

@ -1979,6 +1979,9 @@ nb_NO:
optional: valgfritt
errors_summary_title: "Dette skjemaet inneholder feil:"
dirty_form: "Du har ikke sendt inn endringene dine! Er du sikker på at du vil dra?"
select:
select_placeholder: "Velg…"
none_placeholder: "Ingen"
errors:
required: "Påkrevd"
invalid_url: "Må være en gyldig URL"
@ -5544,6 +5547,8 @@ nb_NO:
label: "Last opp"
selectable_avatars:
title: "Liste over avatarer brukere kan velge mellom"
table_column_heading:
status: "Status"
categories:
all_results: "Alle"
required: "Påkrevd"

View File

@ -2050,6 +2050,9 @@ nl:
optional: optioneel
errors_summary_title: "Dit formulier bevat fouten:"
dirty_form: "Je hebt je wijzigingen niet verzonden! Weet je zeker dat je wilt verlaten?"
select:
select_placeholder: "Selecteren…"
none_placeholder: "Geen"
errors:
required: "Vereist"
invalid_url: "Moet een geldige URL zijn"
@ -6644,6 +6647,8 @@ nl:
title: "Afbeeldingen uploaden"
selectable_avatars:
title: "Lijst van avatars waaruit gebruikers kunnen kiezen"
table_column_heading:
status: "Status"
categories:
all_results: "Alle"
required: "Vereist"

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