discourse/spec/requests/admin/email_templates_controller_spec.rb
Régis Hanol 5f6e69f64f
UX: Display interpolation keys as interactive pills in admin editors (#37254)
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
2026-02-27 21:21:26 +01:00

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