mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-05 00:44:53 +08:00
When editing site texts or email templates, admins need to know which
interpolation keys are available and whether they're being used
correctly.
Previously there was no UI for this.
Show interpolation keys as clickable pill buttons below the editor:
- Unused keys are dimmed — clicking inserts %{key} at the last cursor
position
- Used keys are highlighted green to confirm they're present in the text
- Invalid keys (typos or unknown keys) appear in red as non-clickable
pills
Implementation:
- New `<AdminInterpolationKeys>` template-only component renders the
pills
- New `interpolationKeysWithStatus()` utility in admin/lib centralizes
the
logic for computing key statuses (used/unused/invalid) across both
editors
- Controllers track textarea focus and cursor position via private
fields
(no @Tracked — these are only read imperatively on pill click)
- Uses document.execCommand("insertText") for native undo/redo support
- Fix duplicate keys in backend by using Array#| (set union) instead of
Array#+
<img width="1662" height="1355" alt="2026-02-26 @ 17 09 32"
src="https://github.com/user-attachments/assets/05497832-4e17-4eb2-b4c1-e9e0e036304a"
/>
<img width="1662" height="1355" alt="2026-02-26 @ 17 09 19"
src="https://github.com/user-attachments/assets/f13271ad-511e-4fa1-9bec-eb6fbc7a66d8"
/>
Ref - t/172375
538 lines
18 KiB
Ruby
538 lines
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
RSpec.describe Admin::EmailTemplatesController do
|
|
fab!(:admin)
|
|
fab!(:moderator)
|
|
fab!(:user)
|
|
|
|
def original_text(key)
|
|
I18n.overrides_disabled { I18n.t(key) }
|
|
end
|
|
|
|
let(:original_subject) { original_text("user_notifications.admin_login.subject_template") }
|
|
let(:original_body) { original_text("user_notifications.admin_login.text_body_template") }
|
|
let(:headers) { { ACCEPT: "application/json" } }
|
|
|
|
after do
|
|
TranslationOverride.delete_all
|
|
I18n.reload!
|
|
end
|
|
|
|
describe "#index" do
|
|
context "when logged in as an admin" do
|
|
before { sign_in(admin) }
|
|
|
|
it "should work if you are an admin" do
|
|
get "/admin/email/templates.json"
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
json = response.parsed_body
|
|
expect(json["email_templates"]).to be_present
|
|
end
|
|
|
|
it "returns overridden = true if subject or body has translation_overrides record" do
|
|
put "/admin/email/templates/user_notifications.admin_login",
|
|
params: {
|
|
email_template: {
|
|
subject: original_subject,
|
|
body: original_body,
|
|
},
|
|
},
|
|
headers: headers
|
|
expect(response.status).to eq(200)
|
|
|
|
get "/admin/email/templates.json"
|
|
expect(response.status).to eq(200)
|
|
templates = response.parsed_body["email_templates"]
|
|
template = templates.find { |t| t["id"] == "user_notifications.admin_login" }
|
|
expect(template["can_revert"]).to eq(true)
|
|
|
|
TranslationOverride.destroy_all
|
|
|
|
get "/admin/email/templates.json"
|
|
expect(response.status).to eq(200)
|
|
templates = response.parsed_body["email_templates"]
|
|
template = templates.find { |t| t["id"] == "user_notifications.admin_login" }
|
|
expect(template["can_revert"]).to eq(false)
|
|
end
|
|
|
|
it "returns interpolation_keys for each email template" do
|
|
get "/admin/email/templates.json"
|
|
expect(response.status).to eq(200)
|
|
|
|
templates = response.parsed_body["email_templates"]
|
|
template = templates.find { |t| t["id"] == "user_notifications.admin_login" }
|
|
|
|
expect(template["interpolation_keys"]).to eq(
|
|
%w[base_url email_prefix email_token site_name],
|
|
)
|
|
end
|
|
|
|
it "returns interpolation_keys from body when subject is pluralized" do
|
|
get "/admin/email/templates.json"
|
|
expect(response.status).to eq(200)
|
|
|
|
templates = response.parsed_body["email_templates"]
|
|
template = templates.find { |t| t["id"] == "system_messages.pending_users_reminder" }
|
|
|
|
expect(template["interpolation_keys"]).to eq(%w[base_url])
|
|
end
|
|
|
|
it "returns empty interpolation_keys for templates without any keys" do
|
|
get "/admin/email/templates.json"
|
|
expect(response.status).to eq(200)
|
|
|
|
templates = response.parsed_body["email_templates"]
|
|
template =
|
|
templates.find { |t| t["id"] == "system_messages.download_remote_images_disabled" }
|
|
|
|
expect(template["interpolation_keys"]).to eq([])
|
|
end
|
|
|
|
it "includes custom email template keys added via modifier" do
|
|
custom_keys = %w[custom.email_template_one custom.email_template_two]
|
|
|
|
# Register a modifier to add custom email template keys
|
|
block = Proc.new { |keys| keys + custom_keys }
|
|
plugin_instance = Plugin::Instance.new
|
|
plugin_instance.register_modifier(:email_template_keys, &block)
|
|
|
|
# Get the modified email keys
|
|
modified_keys = Admin::EmailTemplatesController.email_keys
|
|
|
|
# Verify custom keys are included
|
|
expect(modified_keys).to include(*custom_keys)
|
|
|
|
# Verify original keys are still present
|
|
expect(modified_keys).to include("user_notifications.admin_login")
|
|
|
|
DiscoursePluginRegistry.unregister_modifier(plugin_instance, :email_template_keys, &block)
|
|
end
|
|
end
|
|
|
|
shared_examples "email templates inaccessible" do
|
|
it "denies access with a 404 response" do
|
|
get "/admin/email/templates.json"
|
|
|
|
expect(response.status).to eq(404)
|
|
expect(response.parsed_body["errors"]).to include(I18n.t("not_found"))
|
|
end
|
|
end
|
|
|
|
context "when logged in as a moderator" do
|
|
before { sign_in(moderator) }
|
|
|
|
include_examples "email templates inaccessible"
|
|
end
|
|
|
|
context "when logged in as a non-staff user" do
|
|
before { sign_in(user) }
|
|
|
|
include_examples "email templates inaccessible"
|
|
end
|
|
|
|
context "when not logged in" do
|
|
include_examples "email templates inaccessible"
|
|
end
|
|
end
|
|
|
|
describe "#update" do
|
|
context "when logged in as an admin" do
|
|
before { sign_in(admin) }
|
|
|
|
it "returns 'not found' when an unknown email template id is used" do
|
|
put "/admin/email/templates/non_existent_template",
|
|
params: {
|
|
email_template: {
|
|
subject: "Foo",
|
|
body: "Bar",
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
expect(response).not_to be_successful
|
|
|
|
json = response.parsed_body
|
|
expect(json["error_type"]).to eq("not_found")
|
|
end
|
|
|
|
shared_examples "invalid email template" do
|
|
it "returns the right error messages" do
|
|
put "/admin/email/templates/user_notifications.admin_login",
|
|
params: {
|
|
email_template: {
|
|
subject: email_subject,
|
|
body: email_body,
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
json = response.parsed_body
|
|
expect(json).to be_present
|
|
|
|
errors = json["errors"]
|
|
expect(errors).to be_present
|
|
expect(errors).to eq(expected_errors)
|
|
end
|
|
|
|
it "doesn't create translation overrides" do
|
|
put "/admin/email/templates/user_notifications.admin_login",
|
|
params: {
|
|
email_template: {
|
|
subject: email_subject,
|
|
body: email_body,
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
expect(I18n.t("user_notifications.admin_login.subject_template")).to eq(original_subject)
|
|
expect(I18n.t("user_notifications.admin_login.text_body_template")).to eq(original_body)
|
|
end
|
|
|
|
it "doesn't create entries in the Staff Log" do
|
|
put "/admin/email/templates/user_notifications.admin_login",
|
|
params: {
|
|
email_template: {
|
|
subject: email_subject,
|
|
body: email_body,
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
log = UserHistory.find_by_subject("user_notifications.admin_login.subject_template")
|
|
expect(log).to be_nil
|
|
|
|
log = UserHistory.find_by_subject("user_notifications.admin_login.text_body_template")
|
|
expect(log).to be_nil
|
|
end
|
|
end
|
|
|
|
context "when subject is invalid" do
|
|
let(:email_subject) { "%{email_wrongfix} Foo" }
|
|
let(:email_body) { "Body with missing interpolation keys" }
|
|
|
|
let(:expected_errors) do
|
|
[
|
|
"<b>Subject</b>: #{
|
|
I18n.t(
|
|
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
|
|
keys: "email_wrongfix",
|
|
count: 1,
|
|
)
|
|
}",
|
|
]
|
|
end
|
|
|
|
include_examples "invalid email template"
|
|
end
|
|
|
|
context "when body is invalid" do
|
|
let(:email_subject) { "Subject with missing interpolation key" }
|
|
let(:email_body) { "Body with %{invalid} interpolation key" }
|
|
|
|
let(:expected_errors) do
|
|
[
|
|
"<b>Body</b>: #{
|
|
I18n.t(
|
|
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
|
|
keys: "invalid",
|
|
count: 1,
|
|
)
|
|
}",
|
|
]
|
|
end
|
|
|
|
include_examples "invalid email template"
|
|
end
|
|
|
|
context "when subject and body are invalid" do
|
|
let(:email_subject) { "Subject with %{invalid} interpolation key" }
|
|
let(:email_body) { "Body with some invalid interpolation keys: %{invalid}" }
|
|
|
|
let(:expected_errors) do
|
|
[
|
|
"<b>Subject</b>: #{
|
|
I18n.t(
|
|
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
|
|
keys: "invalid",
|
|
count: 1,
|
|
)
|
|
}",
|
|
"<b>Body</b>: #{
|
|
I18n.t(
|
|
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
|
|
keys: "invalid",
|
|
count: 1,
|
|
)
|
|
}",
|
|
]
|
|
end
|
|
|
|
include_examples "invalid email template"
|
|
end
|
|
|
|
context "when body contains malformed interpolation keys" do
|
|
let(:email_subject) { "%{email_prefix} Foo" }
|
|
let(:email_body) { "Hello %{user.username}" }
|
|
|
|
let(:expected_errors) do
|
|
[
|
|
"<b>Body</b>: #{
|
|
I18n.t(
|
|
"activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys",
|
|
keys: "user.username",
|
|
count: 1,
|
|
)
|
|
}",
|
|
]
|
|
end
|
|
|
|
include_examples "invalid email template"
|
|
end
|
|
|
|
context "when subject and body contain all required interpolation keys" do
|
|
let(:email_subject) { "%{email_prefix} Foo" }
|
|
let(:email_body) { "The body contains [%{site_name}](%{base_url}) and %{email_token}." }
|
|
|
|
it "returns the successfully updated email template" do
|
|
put "/admin/email/templates/user_notifications.admin_login",
|
|
params: {
|
|
email_template: {
|
|
subject: email_subject,
|
|
body: email_body,
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
json = response.parsed_body
|
|
expect(json).to be_present
|
|
|
|
template = json["email_template"]
|
|
expect(template).to be_present
|
|
|
|
expect(template["id"]).to eq("user_notifications.admin_login")
|
|
expect(template["title"]).to eq("Admin Login")
|
|
expect(template["subject"]).to eq(email_subject)
|
|
expect(template["body"]).to eq(email_body)
|
|
expect(template["can_revert"]).to eq(true)
|
|
end
|
|
|
|
it "creates translation overrides" do
|
|
put "/admin/email/templates/user_notifications.admin_login",
|
|
params: {
|
|
email_template: {
|
|
subject: email_subject,
|
|
body: email_body,
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
expect(I18n.t("user_notifications.admin_login.subject_template")).to eq(email_subject)
|
|
expect(I18n.t("user_notifications.admin_login.text_body_template")).to eq(email_body)
|
|
end
|
|
|
|
it "creates entries in the Staff Log" do
|
|
put "/admin/email/templates/user_notifications.admin_login",
|
|
params: {
|
|
email_template: {
|
|
subject: email_subject,
|
|
body: email_body,
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
log = UserHistory.find_by_subject("user_notifications.admin_login.subject_template")
|
|
|
|
expect(log).to be_present
|
|
expect(log.action).to eq(UserHistory.actions[:change_site_text])
|
|
expect(log.previous_value).to eq(original_subject)
|
|
expect(log.new_value).to eq(email_subject)
|
|
|
|
log = UserHistory.find_by_subject("user_notifications.admin_login.text_body_template")
|
|
|
|
expect(log).to be_present
|
|
expect(log.action).to eq(UserHistory.actions[:change_site_text])
|
|
expect(log.previous_value).to eq(original_body)
|
|
expect(log.new_value).to eq(email_body)
|
|
end
|
|
end
|
|
|
|
context "when subject has plural keys" do
|
|
it "doesn't update the subject" do
|
|
old_subject = I18n.t("system_messages.pending_users_reminder.subject_template")
|
|
expect(old_subject).to be_a(Hash)
|
|
|
|
put "/admin/email/templates/system_messages.pending_users_reminder",
|
|
params: {
|
|
email_template: {
|
|
subject: "",
|
|
body: "Lorem ipsum",
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
expect(response.status).to eq(200)
|
|
|
|
expect(I18n.t("system_messages.pending_users_reminder.subject_template")).to eq(
|
|
old_subject,
|
|
)
|
|
expect(I18n.t("system_messages.pending_users_reminder.text_body_template")).to eq(
|
|
"Lorem ipsum",
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
shared_examples "email template update not allowed" do
|
|
it "prevents updates with a 404 response" do
|
|
put "/admin/email/templates/some_id",
|
|
params: {
|
|
email_template: {
|
|
subject: "Subject",
|
|
body: "Body",
|
|
},
|
|
},
|
|
headers: headers
|
|
|
|
expect(response.status).to eq(404)
|
|
expect(response.parsed_body["errors"]).to include(I18n.t("not_found"))
|
|
end
|
|
end
|
|
|
|
context "when logged in as a moderator" do
|
|
before { sign_in(moderator) }
|
|
|
|
include_examples "email template update not allowed"
|
|
end
|
|
|
|
context "when logged in as a non-staff user" do
|
|
before { sign_in(user) }
|
|
|
|
include_examples "email template update not allowed"
|
|
end
|
|
|
|
context "when not logged in" do
|
|
include_examples "email template update not allowed"
|
|
end
|
|
end
|
|
|
|
describe "#revert" do
|
|
context "when logged in as an admin" do
|
|
before { sign_in(admin) }
|
|
|
|
it "returns 'not found' when an unknown email template id is used" do
|
|
delete "/admin/email/templates/non_existent_template", headers: headers
|
|
expect(response).not_to be_successful
|
|
|
|
json = response.parsed_body
|
|
expect(json["error_type"]).to eq("not_found")
|
|
end
|
|
|
|
context "when email template has translation overrides" do
|
|
let(:email_subject) { "%{email_prefix} Foo" }
|
|
let(:email_body) { "The body contains [%{site_name}](%{base_url}) and %{email_token}." }
|
|
|
|
before do
|
|
put "/admin/email/templates/user_notifications.admin_login",
|
|
params: {
|
|
email_template: {
|
|
subject: email_subject,
|
|
body: email_body,
|
|
},
|
|
},
|
|
headers: headers
|
|
end
|
|
|
|
it "restores the original subject and body" do
|
|
expect(I18n.t("user_notifications.admin_login.subject_template")).to eq(email_subject)
|
|
expect(I18n.t("user_notifications.admin_login.text_body_template")).to eq(email_body)
|
|
|
|
delete "/admin/email/templates/user_notifications.admin_login", headers: headers
|
|
|
|
expect(I18n.t("user_notifications.admin_login.subject_template")).to eq(original_subject)
|
|
expect(I18n.t("user_notifications.admin_login.text_body_template")).to eq(original_body)
|
|
end
|
|
|
|
it "returns the restored email template" do
|
|
delete "/admin/email/templates/user_notifications.admin_login", headers: headers
|
|
expect(response.status).to eq(200)
|
|
|
|
json = response.parsed_body
|
|
expect(json).to be_present
|
|
|
|
template = json["email_template"]
|
|
expect(template).to be_present
|
|
|
|
expect(template["id"]).to eq("user_notifications.admin_login")
|
|
expect(template["title"]).to eq("Admin Login")
|
|
expect(template["subject"]).to eq(original_subject)
|
|
expect(template["body"]).to eq(original_body)
|
|
expect(template["can_revert"]).to eq(false)
|
|
end
|
|
|
|
it "creates entries in the Staff Log" do
|
|
UserHistory.delete_all
|
|
delete "/admin/email/templates/user_notifications.admin_login", headers: headers
|
|
|
|
log = UserHistory.find_by_subject("user_notifications.admin_login.subject_template")
|
|
|
|
expect(log).to be_present
|
|
expect(log.action).to eq(UserHistory.actions[:change_site_text])
|
|
expect(log.previous_value).to eq(email_subject)
|
|
expect(log.new_value).to eq(original_subject)
|
|
|
|
log = UserHistory.find_by_subject("user_notifications.admin_login.text_body_template")
|
|
|
|
expect(log).to be_present
|
|
expect(log.action).to eq(UserHistory.actions[:change_site_text])
|
|
expect(log.previous_value).to eq(email_body)
|
|
expect(log.new_value).to eq(original_body)
|
|
end
|
|
end
|
|
end
|
|
|
|
shared_examples "email template reversal not allowed" do
|
|
it "prevents reversals with a 404 response" do
|
|
delete "/admin/email/templates/some_id", headers: headers
|
|
|
|
expect(response.status).to eq(404)
|
|
expect(response.parsed_body["errors"]).to include(I18n.t("not_found"))
|
|
end
|
|
end
|
|
|
|
context "when logged in as a moderator" do
|
|
before { sign_in(moderator) }
|
|
|
|
include_examples "email template reversal not allowed"
|
|
end
|
|
|
|
context "when logged in as a non-staff user" do
|
|
before { sign_in(user) }
|
|
|
|
include_examples "email template reversal not allowed"
|
|
end
|
|
|
|
context "when not logged in" do
|
|
include_examples "email template reversal not allowed"
|
|
end
|
|
end
|
|
|
|
it "uses only existing email templates" do
|
|
Admin::EmailTemplatesController.email_keys.each do |key|
|
|
expect(I18n.t(key)).to_not include("Translation missing")
|
|
end
|
|
end
|
|
|
|
describe ".email_keys" do
|
|
it "returns all email templates except security-restricted ones" do
|
|
expected_keys =
|
|
EmailTemplatesFinder.list.reject do |key|
|
|
Admin::EmailTemplatesController.restricted_key?(key)
|
|
end
|
|
expect(Admin::EmailTemplatesController.email_keys).to contain_exactly(*expected_keys)
|
|
end
|
|
end
|
|
end
|