mirror of
https://github.com/discourse/discourse.git
synced 2025-05-31 21:55:25 +08:00
FEATURE: Add translations to posts (#32564)
## 🔍 Overview This update adds the ability for users to manually add translations to specific posts. It adds a 🌐 icon on the post menu where you can click to add translations for posts. It also introduces a new site setting: `content_localization_debug_allowed_groups` which is convenient when debugging localized posts. It adds a globe icon in the post meta data area along with a number of how many languages the post is translated in. Hovering over the icon will show a tooltip with access to editing and deleting the translated posts. ## 📸 Screenshots <img width="1234" alt="Screenshot 2025-05-07 at 13 26 09" src="https://github.com/user-attachments/assets/9d65374d-ee3e-4e8b-b171-b98db6f90f23" /> <img width="300" alt="Screenshot 2025-05-07 at 13 26 41" src="https://github.com/user-attachments/assets/6ee9c5e6-16ed-4dab-97ec-9401804a4ac8" />
This commit is contained in:
@ -7,6 +7,7 @@ import discourseComputed from "discourse/lib/decorators";
|
||||
import escape from "discourse/lib/escape";
|
||||
import { iconHTML } from "discourse/lib/icon-library";
|
||||
import {
|
||||
ADD_TRANSLATION,
|
||||
CREATE_SHARED_DRAFT,
|
||||
CREATE_TOPIC,
|
||||
EDIT,
|
||||
@ -22,6 +23,7 @@ const TITLES = {
|
||||
[CREATE_TOPIC]: "topic.create_long",
|
||||
[CREATE_SHARED_DRAFT]: "composer.create_shared_draft",
|
||||
[EDIT_SHARED_DRAFT]: "composer.edit_shared_draft",
|
||||
[ADD_TRANSLATION]: "composer.translations.title",
|
||||
};
|
||||
|
||||
@classNames("composer-action-title")
|
||||
|
@ -15,6 +15,7 @@ import DEditor from "discourse/components/d-editor";
|
||||
import DEditorPreview from "discourse/components/d-editor-preview";
|
||||
import Wrapper from "discourse/components/form-template-field/wrapper";
|
||||
import PickFilesButton from "discourse/components/pick-files-button";
|
||||
import PostTranslationEditor from "discourse/components/post-translation-editor";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { tinyAvatar } from "discourse/lib/avatar-utils";
|
||||
import { setupComposerPosition } from "discourse/lib/composer/composer-position";
|
||||
@ -96,6 +97,8 @@ const DEBOUNCE_JIT_MS = 2000;
|
||||
@classNameBindings("composer.showToolbar:toolbar-visible", ":wmd-controls")
|
||||
export default class ComposerEditor extends Component {
|
||||
@service composer;
|
||||
@service siteSettings;
|
||||
@service currentUser;
|
||||
|
||||
@tracked preview;
|
||||
|
||||
@ -947,6 +950,21 @@ export default class ComposerEditor extends Component {
|
||||
this._selectedFormTemplateId = value;
|
||||
}
|
||||
|
||||
get showTranslationEditor() {
|
||||
if (
|
||||
!this.siteSettings.experimental_content_localization ||
|
||||
!this.currentUser.can_localize_content
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.composer.model?.action === Composer.ADD_TRANSLATION) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@action
|
||||
async updateFormPreview() {
|
||||
const formTemplateData = prepareFormTemplateData(
|
||||
@ -1018,6 +1036,8 @@ export default class ComposerEditor extends Component {
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else if this.showTranslationEditor}}
|
||||
<PostTranslationEditor @setupEditor={{this.setupEditor}} />
|
||||
{{else}}
|
||||
<DEditor
|
||||
@value={{this.composer.model.reply}}
|
||||
|
@ -780,7 +780,7 @@ export default class DEditor extends Component {
|
||||
</div>
|
||||
</div>
|
||||
<DEditorPreview
|
||||
@preview={{this.preview}}
|
||||
@preview={{if @hijackPreview @hijackPreview this.preview}}
|
||||
@forcePreview={{this.forcePreview}}
|
||||
@onPreviewUpdated={{this.previewUpdated}}
|
||||
@outletArgs={{this.outletArgs}}
|
||||
|
@ -0,0 +1,92 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DEditor from "discourse/components/d-editor";
|
||||
import TextField from "discourse/components/text-field";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
|
||||
|
||||
export default class PostTranslationEditor extends Component {
|
||||
@service composer;
|
||||
@service siteSettings;
|
||||
|
||||
get availableLocales() {
|
||||
return JSON.parse(this.siteSettings.available_locales);
|
||||
}
|
||||
|
||||
findCurrentLocalization() {
|
||||
return this.composer.model.post.post_localizations.find(
|
||||
(localization) =>
|
||||
localization.locale === this.composer.selectedTranslationLocale
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
handleInput(event) {
|
||||
this.composer.model.set("reply", event.target.value);
|
||||
}
|
||||
|
||||
@action
|
||||
updateSelectedLocale(locale) {
|
||||
this.composer.selectedTranslationLocale = locale;
|
||||
|
||||
const currentLocalization = this.findCurrentLocalization();
|
||||
|
||||
if (currentLocalization) {
|
||||
this.composer.model.set("reply", currentLocalization.raw);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<DropdownSelectBox
|
||||
@nameProperty="name"
|
||||
@valueProperty="value"
|
||||
@value={{this.composer.selectedTranslationLocale}}
|
||||
@content={{this.availableLocales}}
|
||||
@onChange={{this.updateSelectedLocale}}
|
||||
@options={{hash
|
||||
icon="globe"
|
||||
showCaret=true
|
||||
filterable=true
|
||||
disabled=this.composer.loading
|
||||
placement="bottom-start"
|
||||
translatedNone=(i18n "composer.translations.select")
|
||||
}}
|
||||
class="translation-selector-dropdown btn-small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.composer.model.post.firstPost}}
|
||||
<div class="topic-title-translator title-and-category with-preview">
|
||||
<TextField
|
||||
@value={{this.composer.model.title}}
|
||||
@id="translated-topic-title"
|
||||
@maxLength={{this.siteSettings.max_topic_title_length}}
|
||||
@placeholder={{this.composer.model.topic.title}}
|
||||
@disabled={{this.composer.loading}}
|
||||
@autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="d-editor translation-editor">
|
||||
<DEditor
|
||||
@value={{readonly this.composer.model.reply}}
|
||||
@change={{this.handleInput}}
|
||||
@placeholder="composer.translations.placeholder"
|
||||
@forcePreview={{true}}
|
||||
@processPreview={{false}}
|
||||
@loading={{this.composer.loading}}
|
||||
@hijackPreview={{this.composer.hijackPreview}}
|
||||
@disabled={{this.composer.disableTextarea}}
|
||||
@onSetup={{@setupEditor}}
|
||||
@disableSubmit={{this.composer.disableSubmit}}
|
||||
@topicId={{this.composer.model.topic.id}}
|
||||
@categoryId={{this.composer.model.category.id}}
|
||||
@outletArgs={{hash composer=this.composer.model editorType="composer"}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
}
|
@ -23,6 +23,7 @@ import {
|
||||
import { i18n } from "discourse-i18n";
|
||||
import PostMenuButtonConfig from "./menu/button-config";
|
||||
import PostMenuButtonWrapper from "./menu/button-wrapper";
|
||||
import PostMenuAddTranslationButton from "./menu/buttons/add-translation";
|
||||
import PostMenuAdminButton from "./menu/buttons/admin";
|
||||
import PostMenuBookmarkButton from "./menu/buttons/bookmark";
|
||||
import PostMenuCopyLinkButton from "./menu/buttons/copy-link";
|
||||
@ -51,6 +52,7 @@ const buttonKeys = Object.freeze({
|
||||
REPLIES: "replies",
|
||||
REPLY: "reply",
|
||||
SHARE: "share",
|
||||
ADD_TRANSLATION: "addTranslation",
|
||||
SHOW_MORE: "showMore",
|
||||
});
|
||||
|
||||
@ -66,6 +68,7 @@ const coreButtonComponents = new Map([
|
||||
[buttonKeys.REPLIES, PostMenuRepliesButton],
|
||||
[buttonKeys.REPLY, PostMenuReplyButton],
|
||||
[buttonKeys.SHARE, PostMenuShareButton],
|
||||
[buttonKeys.ADD_TRANSLATION, PostMenuAddTranslationButton],
|
||||
[buttonKeys.SHOW_MORE, PostMenuShowMoreButton],
|
||||
]);
|
||||
|
||||
|
@ -0,0 +1,53 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import Composer from "discourse/models/composer";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class PostMenuAddTranslationButton extends Component {
|
||||
@service composer;
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
|
||||
@tracked showComposer = false;
|
||||
|
||||
get originalPostContent() {
|
||||
return `<div class='d-editor-translation-preview-wrapper'>
|
||||
<span class='d-editor-translation-preview-wrapper__header'>
|
||||
${i18n("composer.translations.original_content")}
|
||||
</span>
|
||||
${this.args.post.cooked}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@action
|
||||
async addTranslation() {
|
||||
if (
|
||||
!this.currentUser ||
|
||||
!this.siteSettings.experimental_content_localization ||
|
||||
!this.currentUser.can_localize_content
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.composer.open({
|
||||
action: Composer.ADD_TRANSLATION,
|
||||
draftKey: "translation",
|
||||
warningsDisabled: true,
|
||||
hijackPreview: this.originalPostContent,
|
||||
post: this.args.post,
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<DButton
|
||||
class="post-action-menu__add-translation"
|
||||
@title="post.localizations.add"
|
||||
@icon="discourse-add-translation"
|
||||
@action={{this.addTranslation}}
|
||||
...attributes
|
||||
/>
|
||||
</template>
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { service } from "@ember/service";
|
||||
import PostMetaDataDate from "./meta-data/date";
|
||||
import PostMetaDataEditsIndicator from "./meta-data/edits-indicator";
|
||||
import PostMetaDataEmailIndicator from "./meta-data/email-indicator";
|
||||
@ -8,9 +9,12 @@ import PostMetaDataPosterName from "./meta-data/poster-name";
|
||||
import PostMetaDataReadIndicator from "./meta-data/read-indicator";
|
||||
import PostMetaDataReplyToTab from "./meta-data/reply-to-tab";
|
||||
import PostMetaDataSelectPost from "./meta-data/select-post";
|
||||
import PostMetaDataTranslationIndicator from "./meta-data/translation-indicator";
|
||||
import PostMetaDataWhisperIndicator from "./meta-data/whisper-indicator";
|
||||
|
||||
export default class PostMetaData extends Component {
|
||||
@service currentUser;
|
||||
|
||||
get displayPosterName() {
|
||||
return this.args.displayPosterName ?? true;
|
||||
}
|
||||
@ -23,6 +27,13 @@ export default class PostMetaData extends Component {
|
||||
return PostMetaDataReplyToTab.shouldRender(this.args, null, getOwner(this));
|
||||
}
|
||||
|
||||
get shouldDisplayTranslationIndicator() {
|
||||
return (
|
||||
this.currentUser?.can_debug_localizations &&
|
||||
this.args.post?.has_post_localizations
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="topic-meta-data" role="heading" aria-level="2">
|
||||
{{#if this.displayPosterName}}
|
||||
@ -30,6 +41,10 @@ export default class PostMetaData extends Component {
|
||||
{{/if}}
|
||||
|
||||
<div class="post-infos">
|
||||
{{#if this.shouldDisplayTranslationIndicator}}
|
||||
<PostMetaDataTranslationIndicator @post={{@post}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if @post.isWhisper}}
|
||||
<PostMetaDataWhisperIndicator @post={{@post}} />
|
||||
{{/if}}
|
||||
|
@ -0,0 +1,115 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { fn } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Composer from "discourse/models/composer";
|
||||
import PostLocalization from "discourse/models/post-localization";
|
||||
import TopicLocalization from "discourse/models/topic-localization";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
|
||||
export default class PostMetaDataTranslationIndicator extends Component {
|
||||
@service composer;
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
|
||||
get postLocalizationsCount() {
|
||||
return this.args.post?.post_localizations.length;
|
||||
}
|
||||
|
||||
get originalPostContent() {
|
||||
return `<div class='d-editor-translation-preview-wrapper'>
|
||||
<span class='d-editor-translation-preview-wrapper__header'>
|
||||
${i18n("composer.translations.original_content")}
|
||||
</span>
|
||||
${this.args.post.cooked}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@action
|
||||
async editLocalization(locale) {
|
||||
if (
|
||||
!this.currentUser ||
|
||||
!this.siteSettings.experimental_content_localization ||
|
||||
!this.currentUser.can_localize_content
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.composer.open({
|
||||
action: Composer.ADD_TRANSLATION,
|
||||
draftKey: "translation",
|
||||
warningsDisabled: true,
|
||||
hijackPreview: this.originalPostContent,
|
||||
post: this.args.post,
|
||||
selectedTranslationLocale: locale.locale,
|
||||
});
|
||||
this.composer.model.set("reply", locale.raw);
|
||||
}
|
||||
|
||||
@action
|
||||
async deleteLocalization(locale) {
|
||||
try {
|
||||
await PostLocalization.destroy(this.args.post.id, locale);
|
||||
|
||||
if (this.args.post.firstPost) {
|
||||
await TopicLocalization.destroy(this.args.post.topic_id, locale);
|
||||
}
|
||||
} catch (error) {
|
||||
popupAjaxError(error);
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="post-info translations">
|
||||
<DTooltip
|
||||
@triggerClass="btn-flat"
|
||||
@placement="bottom-start"
|
||||
@arrow={{true}}
|
||||
@identifier="post-meta-data-translation-indicator"
|
||||
@interactive={{true}}
|
||||
>
|
||||
<:trigger>
|
||||
<span class="translation-count">{{this.postLocalizationsCount}}</span>
|
||||
{{icon "globe"}}
|
||||
</:trigger>
|
||||
<:content>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{i18n "post.localizations.table.locale"}}</th>
|
||||
<th>{{i18n "post.localizations.table.actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each @post.post_localizations as |localization|}}
|
||||
<tr>
|
||||
<td>{{localization.locale}}</td>
|
||||
<td>
|
||||
<DButton
|
||||
class="btn-primary btn-transparent"
|
||||
@label="post.localizations.table.edit"
|
||||
@action={{fn this.editLocalization localization}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<DButton
|
||||
class="btn-danger btn-transparent"
|
||||
@label="post.localizations.table.delete"
|
||||
@action={{fn this.deleteLocalization localization.locale}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</:content>
|
||||
</DTooltip>
|
||||
</div>
|
||||
</template>
|
||||
}
|
@ -42,7 +42,8 @@ export const CREATE_TOPIC = "createTopic",
|
||||
EDIT = "edit",
|
||||
NEW_PRIVATE_MESSAGE_KEY = "new_private_message",
|
||||
NEW_TOPIC_KEY = "new_topic",
|
||||
EDIT_TOPIC_KEY = "topic_";
|
||||
EDIT_TOPIC_KEY = "topic_",
|
||||
ADD_TRANSLATION = "add_translation";
|
||||
|
||||
function isEdit(action) {
|
||||
return action === EDIT || action === EDIT_SHARED_DRAFT;
|
||||
@ -139,6 +140,7 @@ export default class Composer extends RestModel {
|
||||
static PRIVATE_MESSAGE = PRIVATE_MESSAGE;
|
||||
static REPLY = REPLY;
|
||||
static EDIT = EDIT;
|
||||
static ADD_TRANSLATION = ADD_TRANSLATION;
|
||||
|
||||
// Draft key
|
||||
static NEW_PRIVATE_MESSAGE_KEY = NEW_PRIVATE_MESSAGE_KEY;
|
||||
@ -1297,6 +1299,10 @@ export default class Composer extends RestModel {
|
||||
"minimumPostLength"
|
||||
)
|
||||
canSaveDraft() {
|
||||
if (this.action === Composer.ADD_TRANSLATION) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.draftSaving) {
|
||||
return false;
|
||||
}
|
||||
|
@ -0,0 +1,25 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
export default class PostLocalization extends RestModel {
|
||||
static createOrUpdate(postId, locale, raw) {
|
||||
return ajax("/post_localizations/create_or_update", {
|
||||
type: "POST",
|
||||
data: {
|
||||
post_id: postId,
|
||||
locale,
|
||||
raw,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static destroy(postId, locale) {
|
||||
return ajax("/post_localizations/destroy", {
|
||||
type: "DELETE",
|
||||
data: {
|
||||
post_id: postId,
|
||||
locale,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -209,6 +209,8 @@ export default class Post extends RestModel {
|
||||
@trackedPostProperty wiki;
|
||||
@trackedPostProperty yours;
|
||||
@trackedPostProperty user_custom_fields;
|
||||
@trackedPostProperty has_post_localizations;
|
||||
@trackedPostProperty post_localizations;
|
||||
|
||||
@alias("can_edit") canEdit; // for compatibility with existing code
|
||||
@equal("trust_level", 0) new_user;
|
||||
|
@ -0,0 +1,25 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
export default class TopicLocalization extends RestModel {
|
||||
static createOrUpdate(topicId, locale, title) {
|
||||
return ajax("/topic_localizations/create_or_update", {
|
||||
type: "POST",
|
||||
data: {
|
||||
topic_id: topicId,
|
||||
locale,
|
||||
title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static destroy(topicId, locale) {
|
||||
return ajax("/topic_localizations/destroy", {
|
||||
type: "DELETE",
|
||||
data: {
|
||||
topic_id: topicId,
|
||||
locale,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -15,6 +15,7 @@ import {
|
||||
cannotPostAgain,
|
||||
durationTextFromSeconds,
|
||||
} from "discourse/helpers/slow-mode";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { customPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options";
|
||||
import discourseDebounce from "discourse/lib/debounce";
|
||||
import discourseComputed from "discourse/lib/decorators";
|
||||
@ -45,6 +46,8 @@ import Composer, {
|
||||
SAVE_LABELS,
|
||||
} from "discourse/models/composer";
|
||||
import Draft from "discourse/models/draft";
|
||||
import PostLocalization from "discourse/models/post-localization";
|
||||
import TopicLocalization from "discourse/models/topic-localization";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
async function loadDraft(store, opts = {}) {
|
||||
@ -100,6 +103,7 @@ export default class ComposerService extends Service {
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
@service store;
|
||||
@service toasts;
|
||||
|
||||
@tracked
|
||||
showPreview = this.site.mobileView
|
||||
@ -107,6 +111,7 @@ export default class ComposerService extends Service {
|
||||
: (this.keyValueStore.get("composer.showPreview") || "true") === "true";
|
||||
|
||||
@tracked allowPreview = false;
|
||||
@tracked selectedTranslationLocale = null;
|
||||
checkedMessages = false;
|
||||
messageCount = null;
|
||||
showEditReason = false;
|
||||
@ -362,6 +367,8 @@ export default class ComposerService extends Service {
|
||||
return "composer.create_whisper";
|
||||
} else if (privateMessage && modelAction === Composer.REPLY) {
|
||||
return "composer.create_pm";
|
||||
} else if (modelAction === Composer.ADD_TRANSLATION) {
|
||||
return "composer.translations.save";
|
||||
}
|
||||
|
||||
return SAVE_LABELS[modelAction];
|
||||
@ -1036,6 +1043,10 @@ export default class ComposerService extends Service {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model.action === Composer.ADD_TRANSLATION) {
|
||||
return this.saveTranslation();
|
||||
}
|
||||
|
||||
// Clear the warning state if we're not showing the checkbox anymore
|
||||
if (!this.showWarning) {
|
||||
this.set("model.isWarning", false);
|
||||
@ -1275,6 +1286,44 @@ export default class ComposerService extends Service {
|
||||
return promise;
|
||||
}
|
||||
|
||||
async saveTranslation() {
|
||||
this.set("model.loading", true);
|
||||
|
||||
this.set("lastValidatedAt", Date.now());
|
||||
this.appEvents.trigger("composer-service:last-validated-at-updated", {
|
||||
model: this.model,
|
||||
});
|
||||
|
||||
try {
|
||||
await PostLocalization.createOrUpdate(
|
||||
this.model.post.id,
|
||||
this.selectedTranslationLocale,
|
||||
this.model.reply
|
||||
);
|
||||
|
||||
if (this.model.post.firstPost) {
|
||||
await TopicLocalization.createOrUpdate(
|
||||
this.model.post.topic_id,
|
||||
this.selectedTranslationLocale,
|
||||
this.model.title
|
||||
);
|
||||
}
|
||||
|
||||
this.close();
|
||||
this.toasts.success({
|
||||
duration: 3000,
|
||||
data: {
|
||||
message: i18n("post.localizations.success"),
|
||||
},
|
||||
});
|
||||
this.selectedTranslationLocale = null;
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
} finally {
|
||||
this.set("model.loading", false);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
postWasEnqueued(details) {
|
||||
this.modal.show(PostEnqueuedModal, { model: details });
|
||||
@ -1293,7 +1342,7 @@ export default class ComposerService extends Service {
|
||||
|
||||
@method open
|
||||
@param {Object} opts Options for creating a post
|
||||
@param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage
|
||||
@param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage, addTranslation
|
||||
@param {String} opts.draftKey
|
||||
@param {Post} [opts.post] The post we're replying to
|
||||
@param {Topic} [opts.topic] The topic we're replying to
|
||||
@ -1306,6 +1355,8 @@ export default class ComposerService extends Service {
|
||||
@param {String} [opts.draftSequence]
|
||||
@param {Boolean} [opts.skipJumpOnSave] Option to skip navigating to the post when saved in this composer session
|
||||
@param {Boolean} [opts.skipFormTemplate] Option to skip the form template even if configured for the category
|
||||
@param {String} [opts.hijackPreview] Option to hijack the preview with custom content
|
||||
@param {String} [opts.selectedTranslationLocale] The locale to use for the translation
|
||||
**/
|
||||
async open(opts = {}) {
|
||||
if (!opts.draftKey) {
|
||||
@ -1334,6 +1385,14 @@ export default class ComposerService extends Service {
|
||||
|
||||
this.set("skipFormTemplate", !!opts.skipFormTemplate);
|
||||
|
||||
if (opts.hijackPreview) {
|
||||
this.set("hijackPreview", opts.hijackPreview);
|
||||
}
|
||||
|
||||
if (opts.selectedTranslationLocale) {
|
||||
this.selectedTranslationLocale = opts.selectedTranslationLocale;
|
||||
}
|
||||
|
||||
// Scope the categories drop down to the category we opened the composer with.
|
||||
if (opts.categoryId && !opts.disableScopedCategory) {
|
||||
const category = Category.findById(opts.categoryId);
|
||||
|
@ -1006,6 +1006,31 @@ aside.quote {
|
||||
}
|
||||
}
|
||||
|
||||
.post-info.translations {
|
||||
.fk-d-tooltip__trigger {
|
||||
&-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.translation-count {
|
||||
padding-right: 0.25em;
|
||||
}
|
||||
|
||||
.translation-count,
|
||||
.d-icon {
|
||||
color: var(--primary-med-or-secondary-med);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-meta-data-translation-indicator-content {
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
max-height: 2000px;
|
||||
|
||||
|
@ -370,3 +370,39 @@
|
||||
margin-bottom: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#reply-control.composer-action-add_translation {
|
||||
.topic-title-translator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
|
||||
input {
|
||||
width: 48.5vw;
|
||||
}
|
||||
}
|
||||
|
||||
.translation-selector-dropdown {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.select-kit-body {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.d-editor-preview .d-editor-translation-preview-wrapper {
|
||||
border: 1px dashed var(--primary-300);
|
||||
border-radius: var(--d-border-radius);
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
&__header {
|
||||
background: var(--primary-low);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--d-border-radius);
|
||||
position: absolute;
|
||||
top: 5.5rem;
|
||||
font-size: var(--font-down-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,30 +3,29 @@
|
||||
class PostLocalizationsController < ApplicationController
|
||||
before_action :ensure_logged_in
|
||||
|
||||
def create
|
||||
def create_or_update
|
||||
guardian.ensure_can_localize_content!
|
||||
|
||||
params.require(%i[post_id locale raw])
|
||||
PostLocalizationCreator.create(
|
||||
post_id: params[:post_id],
|
||||
locale: params[:locale],
|
||||
raw: params[:raw],
|
||||
user: current_user,
|
||||
)
|
||||
render json: success_json, status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
guardian.ensure_can_localize_content!
|
||||
|
||||
params.require(%i[post_id locale raw])
|
||||
PostLocalizationUpdater.update(
|
||||
post_id: params[:post_id],
|
||||
locale: params[:locale],
|
||||
raw: params[:raw],
|
||||
user: current_user,
|
||||
)
|
||||
render json: success_json, status: :ok
|
||||
localization = PostLocalization.find_by(post_id: params[:post_id], locale: params[:locale])
|
||||
if localization
|
||||
PostLocalizationUpdater.update(
|
||||
post_id: params[:post_id],
|
||||
locale: params[:locale],
|
||||
raw: params[:raw],
|
||||
user: current_user,
|
||||
)
|
||||
render json: success_json, status: :ok
|
||||
else
|
||||
PostLocalizationCreator.create(
|
||||
post_id: params[:post_id],
|
||||
locale: params[:locale],
|
||||
raw: params[:raw],
|
||||
user: current_user,
|
||||
)
|
||||
render json: success_json, status: :created
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -3,30 +3,30 @@
|
||||
class TopicLocalizationsController < ApplicationController
|
||||
before_action :ensure_logged_in
|
||||
|
||||
def create
|
||||
def create_or_update
|
||||
guardian.ensure_can_localize_content!
|
||||
|
||||
params.require(%i[topic_id locale title])
|
||||
TopicLocalizationCreator.create(
|
||||
topic_id: params[:topic_id],
|
||||
locale: params[:locale],
|
||||
title: params[:title],
|
||||
user: current_user,
|
||||
)
|
||||
render json: success_json, status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
guardian.ensure_can_localize_content!
|
||||
|
||||
params.require(%i[topic_id locale title])
|
||||
TopicLocalizationUpdater.update(
|
||||
topic_id: params[:topic_id],
|
||||
locale: params[:locale],
|
||||
title: params[:title],
|
||||
user: current_user,
|
||||
)
|
||||
render json: success_json, status: :ok
|
||||
topic_localization =
|
||||
TopicLocalization.find_by(topic_id: params[:topic_id], locale: params[:locale])
|
||||
if topic_localization
|
||||
TopicLocalizationUpdater.update(
|
||||
topic_id: params[:topic_id],
|
||||
locale: params[:locale],
|
||||
title: params[:title],
|
||||
user: current_user,
|
||||
)
|
||||
render json: success_json, status: :ok
|
||||
else
|
||||
TopicLocalizationCreator.create(
|
||||
topic_id: params[:topic_id],
|
||||
locale: params[:locale],
|
||||
title: params[:title],
|
||||
user: current_user,
|
||||
)
|
||||
render json: success_json, status: :created
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -76,7 +76,9 @@ class CurrentUserSerializer < BasicUserSerializer
|
||||
:login_method,
|
||||
:has_unseen_features,
|
||||
:can_see_emails,
|
||||
:use_glimmer_post_stream_mode_auto_mode
|
||||
:use_glimmer_post_stream_mode_auto_mode,
|
||||
:can_localize_content,
|
||||
:can_debug_localizations
|
||||
|
||||
delegate :user_stat, to: :object, private: true
|
||||
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
|
||||
@ -324,4 +326,14 @@ class CurrentUserSerializer < BasicUserSerializer
|
||||
def include_use_glimmer_post_stream_mode_auto_mode?
|
||||
scope.user.in_any_groups?(SiteSetting.glimmer_post_stream_mode_auto_groups_map)
|
||||
end
|
||||
|
||||
def can_localize_content
|
||||
return false if !SiteSetting.experimental_content_localization
|
||||
scope.user.in_any_groups?(SiteSetting.experimental_content_localization_allowed_groups_map)
|
||||
end
|
||||
|
||||
def can_debug_localizations
|
||||
return false if !SiteSetting.experimental_content_localization
|
||||
scope.user.in_any_groups?(SiteSetting.content_localization_debug_allowed_groups_map)
|
||||
end
|
||||
end
|
||||
|
5
app/serializers/post_localization_serializer.rb
Normal file
5
app/serializers/post_localization_serializer.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PostLocalizationSerializer < ApplicationSerializer
|
||||
attributes :id, :post_id, :locale, :raw
|
||||
end
|
@ -94,7 +94,9 @@ class PostSerializer < BasicPostSerializer
|
||||
:user_suspended,
|
||||
:user_status,
|
||||
:mentioned_users,
|
||||
:post_url
|
||||
:post_url,
|
||||
:has_post_localizations,
|
||||
:post_localizations
|
||||
|
||||
def initialize(object, opts)
|
||||
super(object, opts)
|
||||
@ -649,6 +651,18 @@ class PostSerializer < BasicPostSerializer
|
||||
SiteSetting.enable_user_status
|
||||
end
|
||||
|
||||
def has_post_localizations
|
||||
object.post_localizations.any?
|
||||
end
|
||||
|
||||
def post_localizations
|
||||
ActiveModel::ArraySerializer.new(
|
||||
object.post_localizations,
|
||||
each_serializer: PostLocalizationSerializer,
|
||||
root: false,
|
||||
).as_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_review_topic?
|
||||
|
@ -2701,6 +2701,13 @@ en:
|
||||
esc_label: "dismiss message"
|
||||
ok_proceed: "Ok, proceed"
|
||||
|
||||
translations:
|
||||
title: "Translating post"
|
||||
select: "Select translation"
|
||||
save: "Save translation"
|
||||
original_content: "Original post content"
|
||||
placeholder: "Enter translation for post here..."
|
||||
|
||||
group_mentioned_limit:
|
||||
one: "<b>Warning!</b> You mentioned <a href='%{group_link}'>%{group}</a>, however this group has more members than the administrator configured mention limit of %{count} user. Nobody will be notified."
|
||||
other: "<b>Warning!</b> You mentioned <a href='%{group_link}'>%{group}</a>, however this group has more members than the administrator configured mention limit of %{count} users. Nobody will be notified."
|
||||
@ -3904,6 +3911,15 @@ en:
|
||||
|
||||
badge_granted_tooltip: "%{username} earned the '%{badge_name}' badge for this post!"
|
||||
|
||||
localizations:
|
||||
table:
|
||||
locale: "Locale"
|
||||
actions: "Actions"
|
||||
edit: "Edit"
|
||||
delete: "Delete"
|
||||
add: "Add new translation"
|
||||
success: "Translations have been updated successfully"
|
||||
|
||||
errors:
|
||||
create: "Sorry, there was an error creating your post. Please try again."
|
||||
edit: "Sorry, there was an error editing your post. Please try again."
|
||||
|
@ -1228,8 +1228,12 @@ Discourse::Application.routes.draw do
|
||||
put "merge_posts"
|
||||
end
|
||||
end
|
||||
resources :post_localizations, only: %i[create update destroy]
|
||||
resources :topic_localizations, only: %i[create update destroy]
|
||||
|
||||
post "/post_localizations/create_or_update", to: "post_localizations#create_or_update"
|
||||
delete "/post_localizations/destroy", to: "post_localizations#destroy"
|
||||
|
||||
post "topic_localizations/create_or_update", to: "topic_localizations#create_or_update"
|
||||
delete "topic_localizations/destroy", to: "topic_localizations#destroy"
|
||||
|
||||
resources :bookmarks, only: %i[create destroy update] do
|
||||
put "toggle_pin"
|
||||
|
@ -269,6 +269,7 @@ basic:
|
||||
- bookmark
|
||||
- admin
|
||||
- reply
|
||||
- addTranslation
|
||||
area: "posts_and_topics"
|
||||
post_menu_hidden_items:
|
||||
client: true
|
||||
@ -285,6 +286,7 @@ basic:
|
||||
- bookmark
|
||||
- admin
|
||||
- reply
|
||||
- addTranslation
|
||||
area: "posts_and_topics"
|
||||
share_links:
|
||||
client: true
|
||||
@ -3975,3 +3977,10 @@ experimental:
|
||||
allow_any: false
|
||||
client: true
|
||||
default: "1|2" # admin, moderator
|
||||
content_localization_debug_allowed_groups:
|
||||
type: group_list
|
||||
list_type: compact
|
||||
allow_any: false
|
||||
client: true
|
||||
default: "1|2" # admin, moderator
|
||||
hidden: true
|
||||
|
@ -93,6 +93,7 @@ module SvgSprite
|
||||
discourse-sparkles
|
||||
discourse-table
|
||||
discourse-threads
|
||||
discourse-add-translation
|
||||
download
|
||||
earth-americas
|
||||
ellipsis
|
||||
|
@ -936,6 +936,7 @@ class TopicView
|
||||
:deleted_by,
|
||||
:incoming_email,
|
||||
:image_upload,
|
||||
:post_localizations,
|
||||
)
|
||||
|
||||
@posts = @posts.includes({ user: :user_status }) if SiteSetting.enable_user_status
|
||||
|
@ -199,6 +199,14 @@ RSpec.describe "posts" do
|
||||
reviewable_score_pending_count: {
|
||||
type: :integer,
|
||||
},
|
||||
has_post_localizations: {
|
||||
type: :boolean,
|
||||
},
|
||||
post_localizations: {
|
||||
type: :array,
|
||||
items: {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -209,6 +209,13 @@
|
||||
"post_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"has_post_localizations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"post_localizations": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"mentioned_users": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
|
@ -211,6 +211,13 @@
|
||||
"post_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"has_post_localizations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"post_localizations": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"mentioned_users": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
|
@ -219,6 +219,13 @@
|
||||
"post_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"has_post_localizations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"post_localizations": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"mentioned_users": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
|
@ -15,77 +15,95 @@ describe PostLocalizationsController do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
describe "#create" do
|
||||
it "creates a new localization" do
|
||||
expect {
|
||||
post "/post_localizations.json",
|
||||
params: {
|
||||
post_id: post_record.id,
|
||||
locale: locale,
|
||||
raw: raw,
|
||||
}
|
||||
}.to change { PostLocalization.count }.by(1)
|
||||
expect(response.status).to eq(201)
|
||||
localization = PostLocalization.last
|
||||
expect(localization.locale).to eq(locale)
|
||||
expect(localization.raw).to eq(raw)
|
||||
expect(localization.post_id).to eq(post_record.id)
|
||||
expect(localization.post_version).to eq(post_record.version)
|
||||
expect(localization.localizer_user_id).to eq(user.id)
|
||||
describe "#create_or_update" do
|
||||
context "when localization does not exist" do
|
||||
it "creates a new localization" do
|
||||
expect {
|
||||
post "/post_localizations/create_or_update.json",
|
||||
params: {
|
||||
post_id: post_record.id,
|
||||
locale: locale,
|
||||
raw: raw,
|
||||
}
|
||||
}.to change { PostLocalization.count }.by(1)
|
||||
|
||||
expect(response.status).to eq(201)
|
||||
localization = PostLocalization.last
|
||||
expect(localization).to have_attributes(
|
||||
locale: locale,
|
||||
raw: raw,
|
||||
post_id: post_record.id,
|
||||
post_version: post_record.version,
|
||||
localizer_user_id: user.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns forbidden if user not in allowed group" do
|
||||
context "when localization already exists" do
|
||||
it "updates the existing localization" do
|
||||
localization = Fabricate(:post_localization, post: post_record, locale: locale, raw: "古い翻訳")
|
||||
new_user = Fabricate(:user, groups: [group])
|
||||
sign_in(new_user)
|
||||
|
||||
expect {
|
||||
post "/post_localizations/create_or_update.json",
|
||||
params: {
|
||||
post_id: post_record.id,
|
||||
locale: locale,
|
||||
raw: raw,
|
||||
}
|
||||
}.not_to change { PostLocalization.count }
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
localization.reload
|
||||
expect(localization.raw).to eq(raw)
|
||||
expect(localization.localizer_user_id).to eq(new_user.id)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns forbidden if user is not in allowed group" do
|
||||
group.remove(user)
|
||||
post "/post_localizations.json", params: { post_id: post_record.id, locale: locale, raw: raw }
|
||||
|
||||
post "/post_localizations/create_or_update.json",
|
||||
params: {
|
||||
post_id: post_record.id,
|
||||
locale: locale,
|
||||
raw: raw,
|
||||
}
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
|
||||
it "returns not found if post does not exist" do
|
||||
post "/post_localizations.json", params: { post_id: -1, locale: locale, raw: raw }
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
post "/post_localizations/create_or_update.json",
|
||||
params: {
|
||||
post_id: -1,
|
||||
locale: locale,
|
||||
raw: raw,
|
||||
}
|
||||
|
||||
describe "#update" do
|
||||
fab!(:post_localization) { Fabricate(:post_localization, post: post_record, locale: "ja") }
|
||||
|
||||
it "updates an existing localization" do
|
||||
put "/post_localizations/#{post_localization.id}.json",
|
||||
params: {
|
||||
post_id: post_record.id,
|
||||
locale: locale,
|
||||
raw: raw,
|
||||
}
|
||||
expect(response.status).to eq(200)
|
||||
expect(PostLocalization.last.raw).to eq(raw)
|
||||
end
|
||||
|
||||
it "returns 404 if localization is missing" do
|
||||
put "/post_localizations.json", params: { post_id: post_record.id, locale: "de", raw: "何か" }
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#destroy" do
|
||||
fab!(:post_localization) { Fabricate(:post_localization, post: post_record, locale: "ja") }
|
||||
|
||||
it "destroys the localization" do
|
||||
Fabricate(:post_localization, post: post_record, locale: locale)
|
||||
|
||||
expect {
|
||||
delete "/post_localizations/#{post_localization.id}.json",
|
||||
delete "/post_localizations/destroy.json",
|
||||
params: {
|
||||
post_id: post_record.id,
|
||||
locale: locale,
|
||||
}
|
||||
}.to change { PostLocalization.count }.by(-1)
|
||||
|
||||
expect(response.status).to eq(204)
|
||||
end
|
||||
|
||||
it "returns 404 if localization is missing" do
|
||||
delete "/post_localizations/289127813837.json",
|
||||
params: {
|
||||
post_id: post_record.id,
|
||||
locale: "nope",
|
||||
}
|
||||
delete "/post_localizations/destroy.json", params: { post_id: post_record.id, locale: "nope" }
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
@ -15,67 +15,68 @@ describe TopicLocalizationsController do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
describe "#create" do
|
||||
it "creates a new localization" do
|
||||
expect {
|
||||
post "/topic_localizations.json", params: { topic_id: topic.id, locale:, title: }
|
||||
}.to change { TopicLocalization.count }.by(1)
|
||||
expect(response.status).to eq(201)
|
||||
expect(TopicLocalization.last).to have_attributes(
|
||||
locale:,
|
||||
title:,
|
||||
topic_id: topic.id,
|
||||
localizer_user_id: user.id,
|
||||
)
|
||||
describe "#create_or_update" do
|
||||
context "when localization does not exist" do
|
||||
it "creates a new localization" do
|
||||
expect {
|
||||
post "/topic_localizations/create_or_update.json",
|
||||
params: {
|
||||
topic_id: topic.id,
|
||||
locale:,
|
||||
title:,
|
||||
}
|
||||
}.to change { TopicLocalization.count }.by(1)
|
||||
expect(response.status).to eq(201)
|
||||
expect(TopicLocalization.last).to have_attributes(
|
||||
locale:,
|
||||
title:,
|
||||
topic_id: topic.id,
|
||||
localizer_user_id: user.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "when localization already exists" do
|
||||
it "updates the existing localization" do
|
||||
topic_localization =
|
||||
Fabricate(:topic_localization, topic: topic, locale: locale, title: "Old title")
|
||||
new_user = Fabricate(:user, groups: [group])
|
||||
sign_in(new_user)
|
||||
|
||||
expect {
|
||||
post "/topic_localizations/create_or_update.json",
|
||||
params: {
|
||||
topic_id: topic.id,
|
||||
locale: locale,
|
||||
title: title,
|
||||
}
|
||||
}.not_to change { TopicLocalization.count }
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
topic_localization.reload
|
||||
expect(topic_localization).to have_attributes(
|
||||
locale: locale,
|
||||
title: title,
|
||||
localizer_user_id: new_user.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns forbidden if user not in allowed group" do
|
||||
group.remove(user)
|
||||
expect {
|
||||
post "/topic_localizations.json", params: { topic_id: topic.id, locale:, title: }
|
||||
post "/topic_localizations/create_or_update.json",
|
||||
params: {
|
||||
topic_id: topic.id,
|
||||
locale:,
|
||||
title:,
|
||||
}
|
||||
}.not_to change { TopicLocalization.count }
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
|
||||
it "returns not found if topic does not exist" do
|
||||
post "/topic_localizations.json", params: { topic_id: -1, locale:, title: }
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#update" do
|
||||
fab!(:topic_localization) { Fabricate(:topic_localization, topic:, locale: "ja") }
|
||||
|
||||
it "updates an existing localization" do
|
||||
new_user = Fabricate(:user, groups: [group])
|
||||
sign_in(new_user)
|
||||
|
||||
put "/topic_localizations/#{topic_localization.id}.json",
|
||||
params: {
|
||||
topic_id: topic.id,
|
||||
locale:,
|
||||
title:,
|
||||
}
|
||||
expect(response.status).to eq(200)
|
||||
topic_localization.reload
|
||||
expect(topic_localization).to have_attributes(locale:, title:, localizer_user_id: new_user.id)
|
||||
end
|
||||
|
||||
it "returns forbidden if user not in allowed group" do
|
||||
group.remove(user)
|
||||
expect {
|
||||
put "/topic_localizations/#{topic_localization.id}.json",
|
||||
params: {
|
||||
topic_id: topic.id,
|
||||
locale:,
|
||||
title:,
|
||||
}
|
||||
}.not_to change { topic_localization }
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
|
||||
it "returns not found if localization is missing" do
|
||||
put "/topic_localizations.json", params: { topic_id: topic.id, locale: "de", title: "何か" }
|
||||
post "/topic_localizations/create_or_update.json", params: { topic_id: -1, locale:, title: }
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
@ -85,11 +86,7 @@ describe TopicLocalizationsController do
|
||||
|
||||
it "destroys the localization" do
|
||||
expect {
|
||||
delete "/topic_localizations/#{topic_localization.id}.json",
|
||||
params: {
|
||||
topic_id: topic.id,
|
||||
locale:,
|
||||
}
|
||||
delete "/topic_localizations/destroy.json", params: { topic_id: topic.id, locale: "ja" }
|
||||
}.to change { TopicLocalization.count }.by(-1)
|
||||
expect(response.status).to eq(204)
|
||||
end
|
||||
@ -97,18 +94,14 @@ describe TopicLocalizationsController do
|
||||
it "returns forbidden if user not allowed" do
|
||||
group.remove(user)
|
||||
expect {
|
||||
delete "/topic_localizations/#{topic_localization.id}.json",
|
||||
params: {
|
||||
topic_id: topic.id,
|
||||
locale:,
|
||||
}
|
||||
delete "/topic_localizations/destroy.json", params: { topic_id: topic.id, locale: "ja" }
|
||||
}.not_to change { TopicLocalization.count }
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
|
||||
it "returns not found if localization is missing" do
|
||||
expect {
|
||||
delete "/topic_localizations/219873918.json", params: { topic_id: 219_873_918, locale: }
|
||||
delete "/topic_localizations/destroy.json", params: { topic_id: -1, locale: "ja" }
|
||||
}.not_to change { TopicLocalization.count }
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
|
18
spec/serializers/post_localization_serializer_spec.rb
Normal file
18
spec/serializers/post_localization_serializer_spec.rb
Normal file
@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe PostLocalizationSerializer do
|
||||
fab!(:post_localization)
|
||||
|
||||
describe "serialized attributes" do
|
||||
it "disaplays every attribute" do
|
||||
serialized = described_class.new(post_localization, scope: Guardian.new, root: false)
|
||||
|
||||
expect(serialized).to have_attributes(
|
||||
id: post_localization.id,
|
||||
post_id: post_localization.post_id,
|
||||
locale: post_localization.locale,
|
||||
raw: post_localization.raw,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
@ -56,6 +56,8 @@ RSpec.describe WebHookPostSerializer do
|
||||
:topic_filtered_posts_count,
|
||||
:topic_archetype,
|
||||
:category_slug,
|
||||
:has_post_localizations,
|
||||
:post_localizations,
|
||||
)
|
||||
end
|
||||
|
||||
|
42
spec/system/post_translation_spec.rb
Normal file
42
spec/system/post_translation_spec.rb
Normal file
@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe "Post translations", type: :system do
|
||||
fab!(:user)
|
||||
fab!(:topic)
|
||||
fab!(:post) { Fabricate(:post, topic: topic, user: user) }
|
||||
let(:topic_page) { PageObjects::Pages::Topic.new }
|
||||
let(:composer) { PageObjects::Components::Composer.new }
|
||||
let(:translation_selector) do
|
||||
PageObjects::Components::SelectKit.new(".translation-selector-dropdown")
|
||||
end
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
SiteSetting.experimental_content_localization = true
|
||||
SiteSetting.experimental_content_localization_allowed_groups = Group::AUTO_GROUPS[:everyone]
|
||||
SiteSetting.post_menu =
|
||||
"read|like|copyLink|flag|edit|bookmark|delete|admin|reply|addTranslation"
|
||||
end
|
||||
|
||||
it "allows a user to translate a post" do
|
||||
topic_page.visit_topic(topic)
|
||||
find("#post_#{post.post_number} .post-action-menu__add-translation").click
|
||||
expect(composer).to be_opened
|
||||
translation_selector.expand
|
||||
translation_selector.select_row_by_value("fr")
|
||||
find("#translated-topic-title").fill_in(with: "Ceci est un sujet de test 0")
|
||||
composer.fill_content("Bonjour le monde")
|
||||
composer.submit
|
||||
post.reload
|
||||
topic.reload
|
||||
|
||||
try_until_success do
|
||||
expect(TopicLocalization.exists?(topic_id: topic.id, locale: "fr")).to be true
|
||||
expect(PostLocalization.exists?(post_id: post.id, locale: "fr")).to be true
|
||||
expect(PostLocalization.find_by(post_id: post.id, locale: "fr").raw).to eq("Bonjour le monde")
|
||||
expect(TopicLocalization.find_by(topic_id: topic.id, locale: "fr").title).to eq(
|
||||
"Ceci est un sujet de test 0",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
@ -92,4 +92,7 @@ Additional SVG icons
|
||||
<path d="M256.483 512C247.794 512 240.071 509.107 234.278 503.321L79.8128 349.026C67.2624 337.454 67.2624 317.203 79.8128 305.631C91.3977 293.094 111.671 293.094 123.256 305.631L256.483 437.746L388.744 305.631C400.329 293.094 420.602 293.094 432.187 305.631C444.738 317.203 444.738 337.454 432.187 349.026L277.722 503.321C271.929 509.107 264.206 512 256.483 512Z"/>
|
||||
<path d="M256.483 0C247.794 0 240.071 2.89303 234.278 8.67907L79.8128 162.974C67.2624 174.546 67.2624 194.797 79.8128 206.369C91.3977 218.906 111.671 218.906 123.256 206.369L256.483 74.2543L388.744 206.369C400.329 218.906 420.602 218.906 432.187 206.369C444.738 194.797 444.738 174.546 432.187 162.974L277.722 8.67907C271.929 2.89303 264.206 0 256.483 0Z"/>
|
||||
</symbol>
|
||||
<symbol id="discourse-add-translation" viewBox="0 0 574 520">
|
||||
<clipPath id="a"><path d="m0 0h574v520h-574z"/></clipPath><g clip-path="url(#a)"><path d="m316.376 352c-4.141 13.095-6.376 27.036-6.376 41.5 0 17.3 3.195 33.853 9.027 49.103-.571 1.347-1.145 2.68-1.727 3.997-10.5 23.6-22.2 40.7-33.5 51.5-11.2 10.7-20.5 13.9-27.8 13.9s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5v-.1c-11.6-26-20.9-58.2-27-94.6zm-181.076 0c10 63.9 29.8 117.4 55.3 151.6-78.4-20.7-142.0005-77.5-172.0004-151.6zm207.726 130.895c3.531 4.122 7.301 8.034 11.29 11.712-8.429 2.498-19.057 5.355-32.816 8.993 8.972-12.033 15.889-16.799 21.526-20.705zm-211.826-290.895c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64h-123.10039c-5.29998-20.5-8.09961-41.9-8.09961-64s2.79963-43.5 8.09961-64zm217.5 0c2.2 20.4 3.3 41.8 3.3 64 0 13.593-.452 26.886-1.283 39.833-7.348 7.282-13.878 15.388-19.442 24.167h-167.875c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64zm98.586 127.438c1.283 0 2.52.197 3.681.562h-7.362c1.162-.365 2.398-.562 3.681-.562zm56.614-127.438c5.3 20.5 8.1 41.9 8.1 64 0 5.302-.163 10.563-.481 15.781-19.121-10.077-40.904-15.781-64.019-15.781-22.983 0-44.648 5.64-63.688 15.609.123-5.167.188-10.371.188-15.609 0-22-1.1-43.4-3.2-64zm-313.3-183.59961c-25.5 34.20001-45.3 87.69991-55.3 151.59961h-116.7004c29.9999-74.0999 93.6004-130.8995 172.0004-151.59961zm65.4-8.40039c7.3 0 16.6 3.19995 27.8 13.7998 11.3 10.8 23 27.9001 33.5 51.5 11.6 26 20.9 58.2002 27 94.7002h-176.6c6.1-36.4 15.5-68.6002 27-94.7002 10.5-23.5999 22.2-40.7 33.5-51.5 11.2-10.59984 20.5-13.7998 27.8-13.7998zm65.4 8.40039c78.3 20.70011 142 77.49981 171.9 151.59961h-116.6c-10-63.8997-29.8-117.3996-55.3-151.59961z"/><path d="m447.5 267c69.864 0 126.5 56.636 126.5 126.5s-56.636 126.5-126.5 126.5-126.5-56.636-126.5-126.5 56.636-126.5 126.5-126.5zm-.197 58.362c-6.224 0-11.252 5.029-11.252 11.253v50.635h-50.636c-6.224 0-11.252 5.029-11.252 11.253s5.028 11.252 11.252 11.252h50.636v50.636c0 6.223 5.028 11.252 11.252 11.252s11.251-5.029 11.252-11.252v-50.636h50.635c6.224 0 11.252-5.028 11.252-11.252s-5.028-11.253-11.252-11.253h-50.635v-50.635c0-6.224-5.028-11.253-11.252-11.253z"/></g>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
Reference in New Issue
Block a user