2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-08-17 18:04:11 +08:00

FEATURE: Add post language on creating a new post (#33160)

This is a second attempt at:
https://github.com/discourse/discourse/pull/33001

We had to [revert the
commit](https://github.com/discourse/discourse/pull/33157) because it
was performing a site-setting check at boot time, which is prone to
issues and not allowed.

This PR:
- re-introduces the changes in the original PR
- a fix by not performing a site-setting check at boot time (verified
by: `SKIP_DB_AND_REDIS=1 DISCOURSE_DEV_DB="nonexist" bin/rails runner
"puts 'booted'"` locally and should be caught by the new CI check
introduced here: https://github.com/discourse/discourse/pull/33158)
- adds a fix to the translation editor to not show the original post
locale in the dropdown, as well as adding an indicator of what the
original post locale is in a small badge in the header:
- ![Screenshot 2025-06-11 at 08 42
36](https://github.com/user-attachments/assets/5f0944c5-ec4d-40b3-b97f-25b1fcab8329)
This commit is contained in:
Keegan George 2025-06-11 10:39:01 -07:00 committed by GitHub
parent 73e9ab1caf
commit 4d380a28e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 281 additions and 73 deletions

View file

@ -1,8 +1,10 @@
import Component from "@ember/component";
import { hash } from "@ember/helper";
import { alias } from "@ember/object/computed";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { classNames } from "@ember-decorators/component";
import PostLanguageSelector from "discourse/components/post-language-selector";
import discourseComputed from "discourse/lib/decorators";
import escape from "discourse/lib/escape";
import { iconHTML } from "discourse/lib/icon-library";
@ -28,6 +30,9 @@ const TITLES = {
@classNames("composer-action-title")
export default class ComposerActionTitle extends Component {
@service currentUser;
@service siteSettings;
@alias("model.replyOptions") options;
@alias("model.action") action;
@ -64,6 +69,20 @@ export default class ComposerActionTitle extends Component {
}
}
get showPostLanguageSelector() {
const allowedActions = [CREATE_TOPIC, EDIT, REPLY];
if (
this.currentUser &&
this.siteSettings.experimental_content_localization &&
this.currentUser.can_localize_content &&
allowedActions.includes(this.model.action)
) {
return true;
}
return false;
}
_formatEditUserPost(userAvatar, userLink, postLink, originalUser) {
let editTitle = `
<a class="post-link" href="${postLink.href}">${postLink.anchor}</a>
@ -114,5 +133,12 @@ export default class ComposerActionTitle extends Component {
<span class="action-title" role="heading" aria-level="1">
{{this.actionTitle}}
</span>
{{#if this.showPostLanguageSelector}}
<PostLanguageSelector
@composerModel={{this.model}}
@selectedLanguage={{this.model.locale}}
/>
{{/if}}
</template>
}

View file

@ -10,7 +10,7 @@ export default class EditCategoryLocalizations extends buildCategoryPanel(
@service siteSettings;
get availableLocales() {
return JSON.parse(this.siteSettings.available_locales);
return this.siteSettings.available_content_localization_locales;
}
<template>

View file

@ -12,21 +12,6 @@ export default class LanguageSwitcher extends Component {
@service siteSettings;
@service router;
get localeOptions() {
const targetLanguages = (
this.siteSettings.experimental_content_localization_supported_locales ||
""
).split("|");
return JSON.parse(this.siteSettings.available_locales)
.filter(({ value }) => targetLanguages.includes(value))
.map(({ name, value }) => {
return {
label: name,
value,
};
});
}
@action
async changeLocale(locale) {
cookie("locale", locale, { path: "/" });
@ -51,13 +36,16 @@ export default class LanguageSwitcher extends Component {
>
<:content>
<DropdownMenu as |dropdown|>
{{#each this.localeOptions as |option|}}
{{#each
this.siteSettings.available_content_localization_locales
as |option|
}}
<dropdown.item
class="locale-options"
data-menu-option-id={{option.value}}
>
<DButton
@translatedLabel={{option.label}}
@translatedLabel={{option.name}}
@action={{fn this.changeLocale option.value}}
/>
</dropdown.item>

View file

@ -27,9 +27,15 @@ export default class PostTranslationsModal extends Component {
}
get originalPostContent() {
const originalLocale =
this.args.model.post?.locale || this.siteSettings.default_locale;
return `<div class='d-editor-translation-preview-wrapper'>
<span class='d-editor-translation-preview-wrapper__header'>
${i18n("composer.translations.original_content")}
<span class='d-editor-translation-preview-wrapper__original-locale'>
${originalLocale}
</span>
</span>
${this.args.model.post.cooked}
</div>`;
@ -62,14 +68,20 @@ export default class PostTranslationsModal extends Component {
this.args.closeModal();
await this.composer.open({
const composerOpts = {
action: Composer.ADD_TRANSLATION,
draftKey: "translation",
warningsDisabled: true,
hijackPreview: this.originalPostContent,
post: this.args.model.post,
selectedTranslationLocale: locale.locale,
});
};
if (locale?.topic_localization) {
composerOpts.topicTitle = locale.topic_localization?.title;
}
await this.composer.open(composerOpts);
this.composer.model.set("reply", locale.raw);
}

View file

@ -0,0 +1,54 @@
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 DropdownMenu from "discourse/components/dropdown-menu";
import DMenu from "float-kit/components/d-menu";
export default class PostLanguageSelector extends Component {
@service siteSettings;
@action
selectPostLanguage(locale) {
this.args.composerModel.locale = locale;
this.dMenu.close();
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
<template>
<DMenu
@identifier="post-language-selector"
@title="Post Language"
@icon="globe"
@label={{@selectedLanguage}}
@modalForMobile={{true}}
@onRegisterApi={{this.onRegisterApi}}
@class="btn-transparent btn-small post-language-selector"
>
<:content>
<DropdownMenu as |dropdown|>
{{#each
this.siteSettings.available_content_localization_locales
as |locale|
}}
<dropdown.item
class="locale=options"
data-menu-option-id={{locale.value}}
>
<DButton
@translatedLabel={{locale.name}}
@title={{locale.value}}
@action={{fn this.selectPostLanguage locale.value}}
/>
</dropdown.item>
{{/each}}
</DropdownMenu>
</:content>
</DMenu>
</template>
}

View file

@ -5,6 +5,8 @@ import { service } from "@ember/service";
import DEditor from "discourse/components/d-editor";
import TextField from "discourse/components/text-field";
import lazyHash from "discourse/helpers/lazy-hash";
import { popupAjaxError } from "discourse/lib/ajax-error";
import PostLocalization from "discourse/models/post-localization";
import { i18n } from "discourse-i18n";
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
@ -12,28 +14,27 @@ export default class PostTranslationEditor extends Component {
@service composer;
@service siteSettings;
get availableLocales() {
const allAvailableLocales = JSON.parse(this.siteSettings.available_locales);
const supportedLocales =
this.siteSettings.experimental_content_localization_supported_locales.split(
"|"
async findCurrentLocalization() {
try {
const { post_localizations } = await PostLocalization.find(
this.composer.model.post.id
);
if (!supportedLocales.includes(this.siteSettings.default_locale)) {
supportedLocales.push(this.siteSettings.default_locale);
return post_localizations.find(
(localization) =>
localization.locale === this.composer.selectedTranslationLocale
);
} catch (error) {
popupAjaxError(error);
}
const filtered = allAvailableLocales.filter((locale) => {
return supportedLocales.includes(locale.value);
});
return filtered;
}
findCurrentLocalization() {
return this.composer.model.post.post_localizations.find(
(localization) =>
localization.locale === this.composer.selectedTranslationLocale
get availableContentLocalizationLocales() {
const originalPostLocale =
this.composer.model?.post?.locale || this.siteSettings.default_locale;
return this.siteSettings.available_content_localization_locales.filter(
(locale) => locale.value !== originalPostLocale
);
}
@ -43,13 +44,25 @@ export default class PostTranslationEditor extends Component {
}
@action
updateSelectedLocale(locale) {
async updateSelectedLocale(locale) {
this.composer.selectedTranslationLocale = locale;
const currentLocalization = this.findCurrentLocalization();
const currentLocalization = await this.findCurrentLocalization();
if (currentLocalization) {
this.composer.model.set("reply", currentLocalization.raw);
if (currentLocalization?.topic_localization) {
this.composer.model.set(
"title",
currentLocalization.topic_localization.title
);
}
} else {
this.composer.model.setProperties({
reply: "",
title: "",
});
}
}
@ -59,7 +72,7 @@ export default class PostTranslationEditor extends Component {
@nameProperty="name"
@valueProperty="value"
@value={{this.composer.selectedTranslationLocale}}
@content={{this.availableLocales}}
@content={{this.availableContentLocalizationLocales}}
@onChange={{this.updateSelectedLocale}}
@options={{hash
icon="globe"

View file

@ -19,9 +19,15 @@ export default class PostMenuAddTranslationButton extends Component {
@tracked showComposer = false;
get originalPostContent() {
const originalLocale =
this.args.post?.locale || this.siteSettings.default_locale;
return `<div class='d-editor-translation-preview-wrapper'>
<span class='d-editor-translation-preview-wrapper__header'>
${i18n("composer.translations.original_content")}
<span class='d-editor-translation-preview-wrapper__original-locale'>
${originalLocale}
</span>
</span>
${this.args.post.cooked}
</div>`;

View file

@ -73,11 +73,13 @@ const CLOSED = "closed",
shared_draft: "sharedDraft",
no_bump: "noBump",
draft_key: "draftKey",
locale: "locale",
},
_update_serializer = {
raw: "reply",
topic_id: "topic.id",
original_text: "originalText",
locale: "locale",
},
_edit_topic_serializer = {
title: "topic.title",
@ -86,6 +88,7 @@ const CLOSED = "closed",
featuredLink: "topic.featured_link",
original_title: "originalTitle",
original_tags: "originalTags",
locale: "locale",
},
_draft_serializer = {
reply: "reply",
@ -103,6 +106,7 @@ const CLOSED = "closed",
original_text: "originalText",
original_title: "originalTitle",
original_tags: "originalTags",
locale: "locale",
},
_add_draft_fields = {},
FAST_REPLY_LENGTH_THRESHOLD = 10000;
@ -114,6 +118,7 @@ export const SAVE_LABELS = {
[PRIVATE_MESSAGE]: "composer.create_pm",
[CREATE_SHARED_DRAFT]: "composer.create_shared_draft",
[EDIT_SHARED_DRAFT]: "composer.save_edit",
[ADD_TRANSLATION]: "composer.translations.save",
};
export const SAVE_ICONS = {
@ -204,6 +209,7 @@ export default class Composer extends RestModel {
@tracked post;
@tracked reply;
@tracked whisper;
@tracked locale = this.post?.locale || this.siteSettings.default_locale;
unlistTopic = false;
noBump = false;
@ -1176,6 +1182,7 @@ export default class Composer extends RestModel {
typingTime: this.typingTime,
composerTime: this.composerTime,
metaData: this.metaData,
locale: this.locale,
});
this.serialize(_create_serializer, createdPost);

View file

@ -367,8 +367,6 @@ 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];
@ -1476,6 +1474,7 @@ export default class ComposerService extends Service {
action: CREATE_TOPIC,
draftKey: this.topicDraftKey,
draftSequence: 0,
locale: this.siteSettings.default_locale,
});
}
@ -1518,6 +1517,8 @@ export default class ComposerService extends Service {
isWarning: false,
hasTargetGroups: opts.hasGroups,
warningsDisabled: opts.warningsDisabled,
locale:
opts?.locale || opts?.post?.locale || this.siteSettings.default_locale,
});
if (!this.model.targetRecipients) {

View file

@ -9,3 +9,15 @@
color: var(--quaternary);
}
}
.post-language-selector-content {
z-index: z("composer", "dropdown");
}
.post-language-selector-trigger {
margin-left: 1rem;
.d-button-label {
text-transform: uppercase;
}
}

View file

@ -420,5 +420,14 @@
font-size: var(--font-down-1-rem);
color: var(--primary-high);
}
&__original-locale {
margin-left: 0.5rem;
text-transform: uppercase;
font-size: var(--font-down-2);
background: var(--tertiary-low);
padding: 0.25rem 0.5rem;
border-radius: var(--d-border-radius);
}
}
}

View file

@ -6,20 +6,33 @@ class PostLocalizationsController < ApplicationController
def show
guardian.ensure_can_localize_content!
params.require(%i[post_id])
localizations = PostLocalization.where(post_id: params[:post_id])
params.require(:post_id)
if localizations
render json:
post = Post.find_by(id: params[:post_id])
return render json_error(I18n.t("not_found"), status: :not_found) if post.blank?
post_localizations = PostLocalization.where(post_id: post.id)
topic_localizations_by_locale = {}
if post.is_first_post?
TopicLocalization
.where(topic_id: post.topic_id)
.each { |tl| topic_localizations_by_locale[tl.locale] = tl }
end
post_localizations.each do |pl|
pl.define_singleton_method(:topic_localization) { topic_localizations_by_locale[pl.locale] }
end
render json: {
post_localizations:
ActiveModel::ArraySerializer.new(
localizations,
post_localizations,
each_serializer: PostLocalizationSerializer,
root: false,
).as_json,
status: :ok
else
render json_error I18n.t("not_found"), status: :not_found
end
},
status: :ok
end
def create_or_update

View file

@ -244,7 +244,11 @@ class PostsController < ApplicationController
guardian.ensure_can_edit!(post)
changes = { raw: params[:post][:raw], edit_reason: params[:post][:edit_reason] }
changes = {
raw: params[:post][:raw],
edit_reason: params[:post][:edit_reason],
locale: params[:post][:locale],
}
Post.plugin_permitted_update_params.keys.each { |param| changes[param] = params[:post][param] }
@ -853,6 +857,7 @@ class PostsController < ApplicationController
visible
draft_key
composer_version
locale
]
Post.plugin_permitted_create_params.each do |key, value|

View file

@ -115,6 +115,20 @@ class SiteSetting < ActiveRecord::Base
LocaleSiteSetting.values.to_json
end
client_settings << :available_content_localization_locales
def self.available_content_localization_locales
return [] if !SiteSetting.experimental_content_localization?
supported_locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
default_locale = SiteSetting.default_locale
if default_locale.present? && !supported_locales.include?(default_locale)
supported_locales << default_locale
end
LocaleSiteSetting.values.select { |locale| supported_locales.include?(locale[:value]) }
end
def self.topic_title_length
min_topic_title_length..max_topic_title_length
end

View file

@ -1,5 +1,13 @@
# frozen_string_literal: true
class PostLocalizationSerializer < ApplicationSerializer
attributes :id, :post_id, :locale, :raw
attributes :id, :post_id, :post_version, :locale, :raw, :topic_localization
def topic_localization
TopicLocalizationSerializer.new(object.topic_localization, root: false).as_json
end
def include_topic_localization?
object.respond_to?(:topic_localization) && object.topic_localization.present?
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class TopicLocalizationSerializer < ApplicationSerializer
attributes :id, :topic_id, :locale, :title, :fancy_title
end

View file

@ -555,6 +555,7 @@ class PostCreator
via_email
raw_email
action_code
locale
].each { |a| post.public_send("#{a}=", @opts[a]) if @opts[a].present? }
post.extract_quoted_post_numbers

View file

@ -40,7 +40,7 @@ class PostRevisor
end
end
POST_TRACKED_FIELDS = %w[raw cooked edit_reason user_id wiki post_type]
POST_TRACKED_FIELDS = %w[raw cooked edit_reason user_id wiki post_type locale]
attr_reader :category_changed, :post_revision

View file

@ -14,13 +14,14 @@ RSpec.describe "Anonymous user language switcher", type: :system do
end
before do
SiteSetting.default_locale = "en"
SiteSetting.experimental_content_localization_supported_locales = "es|ja"
SiteSetting.experimental_content_localization = true
SiteSetting.allow_user_locale = true
SiteSetting.set_locale_from_cookie = true
end
it "only shows the language switcher based on what is in target languages" do
SiteSetting.experimental_content_localization_supported_locales = "es|ja"
SiteSetting.experimental_anon_language_switcher = false
visit("/")
@ -30,6 +31,7 @@ RSpec.describe "Anonymous user language switcher", type: :system do
visit("/")
switcher.expand
expect(switcher).to have_content("English (US)")
expect(switcher).to have_content("日本語")
expect(switcher).to have_content("Español")

View file

@ -20,7 +20,16 @@ describe "Edit Category Localizations", type: :system do
end
context "when content localization setting is enabled" do
before { SiteSetting.experimental_content_localization = true }
before do
SiteSetting.default_locale = "en"
SiteSetting.experimental_content_localization = true
SiteSetting.experimental_content_localization_supported_locales = "es|fr"
SiteSetting.experimental_content_localization_allowed_groups = Group::AUTO_GROUPS[:everyone]
if SiteSetting.client_settings.exclude?(:available_content_localization_locales)
SiteSetting.client_settings << :available_content_localization_locales
end
end
it "should show the localization tab" do
category_page.visit_settings(category)

View file

@ -1,19 +1,25 @@
# frozen_string_literal: true
describe "Post translations", type: :system do
fab!(:user)
POST_LANGUAGE_SWITCHER_SELECTOR = "button[data-identifier='post-language-selector']"
fab!(:admin)
fab!(:topic)
fab!(:post) { Fabricate(:post, topic: topic, user: user) }
fab!(:post) { Fabricate(:post, topic: topic, user: admin) }
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
let(:post_language_selector) do
PageObjects::Components::DMenu.new(POST_LANGUAGE_SWITCHER_SELECTOR)
end
let(:view_translations_modal) { PageObjects::Modals::ViewTranslationsModal.new }
before do
sign_in(user)
SiteSetting.experimental_content_localization_supported_locales = "en|fr|es|pt_BR"
sign_in(admin)
SiteSetting.default_locale = "en"
SiteSetting.experimental_content_localization_supported_locales = "fr|es|pt_BR"
SiteSetting.experimental_content_localization = true
SiteSetting.experimental_content_localization_allowed_groups = Group::AUTO_GROUPS[:everyone]
SiteSetting.post_menu =
@ -25,23 +31,13 @@ describe "Post translations", type: :system do
topic_page.visit_topic(topic)
find("#post_#{post.post_number} .post-action-menu__add-translation").click
translation_selector.expand
expect(all(".translation-selector-dropdown .select-kit-collection li").count).to eq(4)
expect(translation_selector).to have_option_value("en")
expect(all(".translation-selector-dropdown .select-kit-collection li").count).to eq(3)
expect(translation_selector).to have_option_value("fr")
expect(translation_selector).to have_option_value("es")
expect(translation_selector).to have_option_value("pt_BR")
expect(translation_selector).to have_no_option_value("de")
end
it "always includes the site's default locale in the list of available languages" do
SiteSetting.default_locale = "de"
topic_page.visit_topic(topic)
find("#post_#{post.post_number} .post-action-menu__add-translation").click
translation_selector.expand
expect(all(".translation-selector-dropdown .select-kit-collection li").count).to eq(5)
expect(translation_selector).to have_option_value("de")
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
@ -75,7 +71,7 @@ describe "Post translations", type: :system do
it "allows a user to add a new translation" do
topic_page.visit_topic(topic)
find("#post_#{post.post_number} .post-action-menu-edit-translations-trigger").click
find("#post_1 .post-action-menu-edit-translations-trigger").click
find(".update-translations-menu__add .post-action-menu__add-translation").click
expect(composer).to be_opened
translation_selector.expand
@ -153,4 +149,31 @@ describe "Post translations", type: :system do
end
end
end
context "when creating a new post in a different locale" do
it "should only show the languages listed in the site setting and default locale" do
visit("/latest")
page.find("#create-topic").click
post_language_selector.expand
expect(post_language_selector).to have_content("English (US)") # default locale
expect(post_language_selector).to have_content("Français")
expect(post_language_selector).to have_content("Español")
expect(post_language_selector).to have_content("Português (BR)")
end
it "should allow a user to create a post in a different locale" do
visit("/latest")
page.find("#create-topic").click
post_language_selector.expand
post_language_selector.option(".dropdown-menu__item[data-menu-option-id='fr']").click
composer.fill_title("Ceci est un sujet de test 1")
composer.fill_content("Bonjour le monde")
composer.submit
try_until_success do
updated_post = Topic.last.posts.first
expect(updated_post.locale).to eq("fr")
end
end
end
end