mirror of
https://github.com/discourse/discourse.git
synced 2025-06-27 20:52:32 +00:00
DEV: Partially refactor themes controller to services (#33141)
This commit starts to introduce services to replace actions in the ThemesController. We will start with the low langing fruit: * Create * Destroy * BulkDestroy * GetTranslations Endpoints like #import and #update will be much harder. Also, the https://github.com/discourse/discourse-theme-creator plugin overrides some of these controller actions directly, so we need to be careful.
This commit is contained in:
parent
5902e1141c
commit
4b947d2404
@ -192,25 +192,19 @@ class Admin::ThemesController < Admin::AdminController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
ban_in_allowlist_mode!
|
Themes::Create.call(
|
||||||
|
params: theme_params.to_unsafe_h.merge(user_id: theme_user.id),
|
||||||
@theme =
|
guardian:,
|
||||||
Theme.new(
|
) do
|
||||||
name: theme_params[:name],
|
on_success { |theme:| render json: serialize_data(theme, ThemeSerializer), status: :created }
|
||||||
user_id: theme_user.id,
|
on_failed_contract do |contract|
|
||||||
user_selectable: theme_params[:user_selectable] || false,
|
render json: failed_json.merge(errors: contract.errors.full_messages), status: 400
|
||||||
color_scheme_id: theme_params[:color_scheme_id],
|
end
|
||||||
component: [true, "true"].include?(theme_params[:component]),
|
on_failed_policy(:ensure_remote_themes_are_not_allowlisted) { raise Discourse::InvalidAccess }
|
||||||
)
|
on_model_errors { |theme:| render json: theme.errors, status: :unprocessable_entity }
|
||||||
set_fields
|
on_model_not_found(:theme) do |result|
|
||||||
|
raise Discourse::NotFound if !result.exception
|
||||||
respond_to do |format|
|
render json: failed_json.merge(errors: result.exception.message), status: 400
|
||||||
if @theme.save
|
|
||||||
update_default_theme
|
|
||||||
log_theme_change(nil, @theme)
|
|
||||||
format.json { render json: serialize_data(@theme, ThemeSerializer), status: :created }
|
|
||||||
else
|
|
||||||
format.json { render json: @theme.errors, status: :unprocessable_entity }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -280,27 +274,23 @@ class Admin::ThemesController < Admin::AdminController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
raise Discourse::InvalidAccess if params[:id].to_i.negative?
|
Themes::Destroy.call(service_params) do
|
||||||
@theme = Theme.find_by(id: params[:id])
|
on_success { render json: {}, status: :no_content }
|
||||||
raise Discourse::InvalidParameters.new(:id) unless @theme
|
on_failed_contract do |contract|
|
||||||
|
render json: failed_json.merge(errors: contract.errors.full_messages), status: 400
|
||||||
StaffActionLogger.new(current_user).log_theme_destroy(@theme)
|
end
|
||||||
@theme.destroy
|
on_model_not_found(:theme) { raise Discourse::NotFound }
|
||||||
|
end
|
||||||
respond_to { |format| format.json { head :no_content } }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def bulk_destroy
|
def bulk_destroy
|
||||||
params[:theme_ids] = params[:theme_ids].filter { |id| id.to_i.positive? }
|
Themes::BulkDestroy.call(service_params) do
|
||||||
themes = Theme.where(id: params[:theme_ids])
|
on_success { render json: {}, status: :no_content }
|
||||||
raise Discourse::InvalidParameters.new(:id) if themes.blank?
|
on_failed_contract do |contract|
|
||||||
|
render json: failed_json.merge(errors: contract.errors.full_messages), status: 400
|
||||||
ActiveRecord::Base.transaction do
|
end
|
||||||
themes.each { |theme| StaffActionLogger.new(current_user).log_theme_destroy(theme) }
|
on_model_not_found(:themes) { raise Discourse::NotFound }
|
||||||
themes.destroy_all
|
|
||||||
end
|
end
|
||||||
|
|
||||||
respond_to { |format| format.json { head :no_content } }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@ -326,22 +316,14 @@ class Admin::ThemesController < Admin::AdminController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_translations
|
def get_translations
|
||||||
params.require(:locale)
|
Themes::GetTranslations.call(service_params) do
|
||||||
if I18n.available_locales.exclude?(params[:locale].to_sym)
|
on_success { |translations:| render(json: success_json.merge(translations:)) }
|
||||||
raise Discourse::InvalidParameters.new(:locale)
|
on_failed_contract do |contract|
|
||||||
end
|
render json: failed_json.merge(errors: contract.errors.full_messages), status: 400
|
||||||
|
|
||||||
I18n.locale = params[:locale]
|
|
||||||
|
|
||||||
@theme = Theme.find_by(id: params[:id])
|
|
||||||
raise Discourse::InvalidParameters.new(:id) unless @theme
|
|
||||||
|
|
||||||
translations =
|
|
||||||
@theme.translations.map do |translation|
|
|
||||||
{ key: translation.key, value: translation.value, default: translation.default }
|
|
||||||
end
|
end
|
||||||
|
on_failed_policy(:validate_locale) { raise Discourse::InvalidParameters.new(:locale) }
|
||||||
render json: { translations: translations }, status: :ok
|
on_model_not_found(:theme) { raise Discourse::NotFound }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_single_setting
|
def update_single_setting
|
||||||
@ -408,7 +390,7 @@ class Admin::ThemesController < Admin::AdminController
|
|||||||
def update_default_theme
|
def update_default_theme
|
||||||
if theme_params.key?(:default)
|
if theme_params.key?(:default)
|
||||||
is_default = theme_params[:default].to_s == "true"
|
is_default = theme_params[:default].to_s == "true"
|
||||||
if @theme.id == SiteSetting.default_theme_id && !is_default
|
if @theme.default? && !is_default
|
||||||
Theme.clear_default!
|
Theme.clear_default!
|
||||||
elsif is_default
|
elsif is_default
|
||||||
@theme.set_default!
|
@theme.set_default!
|
||||||
|
@ -21,6 +21,12 @@ class Theme < ActiveRecord::Base
|
|||||||
class SettingsMigrationError < StandardError
|
class SettingsMigrationError < StandardError
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class InvalidFieldTargetError < StandardError
|
||||||
|
end
|
||||||
|
|
||||||
|
class InvalidFieldTypeError < StandardError
|
||||||
|
end
|
||||||
|
|
||||||
attr_accessor :child_components
|
attr_accessor :child_components
|
||||||
attr_accessor :skip_child_components_update
|
attr_accessor :skip_child_components_update
|
||||||
|
|
||||||
@ -630,11 +636,21 @@ class Theme < ActiveRecord::Base
|
|||||||
name = name.to_s
|
name = name.to_s
|
||||||
|
|
||||||
target_id = Theme.targets[target.to_sym]
|
target_id = Theme.targets[target.to_sym]
|
||||||
raise "Unknown target #{target} passed to set field" unless target_id
|
if target_id.blank?
|
||||||
|
raise InvalidFieldTargetError.new("Unknown target #{target} passed to set field")
|
||||||
|
end
|
||||||
|
|
||||||
type_id ||=
|
type_id ||=
|
||||||
type ? ThemeField.types[type.to_sym] : ThemeField.guess_type(name: name, target: target)
|
type ? ThemeField.types[type.to_sym] : ThemeField.guess_type(name: name, target: target)
|
||||||
raise "Unknown type #{type} passed to set field" unless type_id
|
if type_id.blank?
|
||||||
|
if type.present?
|
||||||
|
raise InvalidFieldTypeError.new("Unknown type #{type} passed to set field")
|
||||||
|
else
|
||||||
|
raise InvalidFieldTypeError.new(
|
||||||
|
"No type could be guessed for field #{name} for target #{target}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
value ||= ""
|
value ||= ""
|
||||||
|
|
||||||
|
@ -3,7 +3,8 @@
|
|||||||
class ThemeField < ActiveRecord::Base
|
class ThemeField < ActiveRecord::Base
|
||||||
MIGRATION_NAME_PART_MAX_LENGTH = 150
|
MIGRATION_NAME_PART_MAX_LENGTH = 150
|
||||||
|
|
||||||
# This string is not 'secret'. It's just randomized to avoid accidental clashes with genuine theme field content.
|
# This string is not 'secret'. It's just randomized to avoid accidental
|
||||||
|
# clashes with genuine theme field content.
|
||||||
CSP_NONCE_PLACEHOLDER = "__CSP__NONCE__PLACEHOLDER__f72bff1b1768168a34ee092ce759f192__"
|
CSP_NONCE_PLACEHOLDER = "__CSP__NONCE__PLACEHOLDER__f72bff1b1768168a34ee092ce759f192__"
|
||||||
|
|
||||||
belongs_to :upload
|
belongs_to :upload
|
||||||
|
57
app/services/themes/bulk_destroy.rb
Normal file
57
app/services/themes/bulk_destroy.rb
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Destroys multiple themes and logs the staff action. Related records are destroyed
|
||||||
|
# by ActiveRecord dependent: :destroy. Cannot be used to destroy system themes.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# Themes::Destroy.call(
|
||||||
|
# guardian: guardian,
|
||||||
|
# params: {
|
||||||
|
# theme_ids: [theme_1.id, theme_2.id],
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
class Themes::BulkDestroy
|
||||||
|
include Service::Base
|
||||||
|
|
||||||
|
# @!method self.call(guardian:, params:)
|
||||||
|
# @param [guardian] guardian
|
||||||
|
# @param [hash] params
|
||||||
|
# @option params [array] :theme_ids The ids of the themes to destroy, must be positive integers.
|
||||||
|
# @return [service::base::context]
|
||||||
|
|
||||||
|
params do
|
||||||
|
attribute :theme_ids, :array
|
||||||
|
validates :theme_ids, presence: true, length: { minimum: 1, maximum: 50 }
|
||||||
|
validate :theme_ids_must_be_positive, if: -> { theme_ids.present? }
|
||||||
|
|
||||||
|
before_validation { self.theme_ids = theme_ids.map(&:to_i).uniq if theme_ids.present? }
|
||||||
|
|
||||||
|
def theme_ids_must_be_positive
|
||||||
|
return if theme_ids.all?(&:positive?)
|
||||||
|
errors.add(:theme_ids, I18n.t("errors.messages.must_all_be_positive"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
model :themes
|
||||||
|
|
||||||
|
transaction do
|
||||||
|
step :log_themes_destroy
|
||||||
|
step :destroy_themes
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_themes(params:)
|
||||||
|
Theme.where(id: params.theme_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_themes_destroy(themes:, guardian:)
|
||||||
|
staff_action_logger = StaffActionLogger.new(guardian.user)
|
||||||
|
themes.each { |theme| staff_action_logger.log_theme_destroy(theme) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_themes(themes:)
|
||||||
|
themes.destroy_all
|
||||||
|
end
|
||||||
|
end
|
85
app/services/themes/create.rb
Normal file
85
app/services/themes/create.rb
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Creates a new theme with the provided parameters. Themes can be created
|
||||||
|
# with various attributes including name, user selectability, color scheme,
|
||||||
|
# and theme fields.
|
||||||
|
#
|
||||||
|
# Also used to create theme components.
|
||||||
|
#
|
||||||
|
# The theme can optionally be set as the default theme, overriding SiteSetting.default_theme_id.
|
||||||
|
# The theme will then be used for all users on the site who haven't specifically set their
|
||||||
|
# theme preference.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# Themes::Create.call(
|
||||||
|
# guardian: guardian,
|
||||||
|
# params: {
|
||||||
|
# name: "My Theme",
|
||||||
|
# user_selectable: true,
|
||||||
|
# color_scheme_id: 1,
|
||||||
|
# component: false,
|
||||||
|
# theme_fields: [
|
||||||
|
# { name: "header", target: "common", value: "content", type_id: 1 }
|
||||||
|
# ],
|
||||||
|
# default: false
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
|
||||||
|
class Themes::Create
|
||||||
|
include Service::Base
|
||||||
|
|
||||||
|
# @!method self.call(guardian:, params:)
|
||||||
|
# @param [Guardian] guardian
|
||||||
|
# @param [Hash] params
|
||||||
|
# @option params String :name The name of the theme
|
||||||
|
# @option params Integer :user_id The ID of the user creating the theme
|
||||||
|
# @option params [Boolean] :user_selectable Whether the theme can be selected by users
|
||||||
|
# @option params [Integer] :color_scheme_id The ID of the color palette to use
|
||||||
|
# @option params [Boolean] :component Whether this is a theme component. These cannot be user_selectable or have a color_scheme_id
|
||||||
|
# @option params [Array] :theme_fields Array of theme field attributes
|
||||||
|
# @option params [Boolean] :default Whether to set this as the default theme
|
||||||
|
# @return [Service::Base::Context]
|
||||||
|
|
||||||
|
params do
|
||||||
|
attribute :name, :string
|
||||||
|
attribute :user_id, :integer
|
||||||
|
attribute :user_selectable, :boolean, default: false
|
||||||
|
attribute :color_scheme_id, :integer
|
||||||
|
attribute :component, :boolean, default: false
|
||||||
|
attribute :theme_fields, :array
|
||||||
|
attribute :default, :boolean, default: false
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :user_id, presence: true
|
||||||
|
validates :theme_fields, length: { maximum: 100 }
|
||||||
|
end
|
||||||
|
|
||||||
|
policy :ensure_remote_themes_are_not_allowlisted
|
||||||
|
|
||||||
|
transaction do
|
||||||
|
model :theme, :create_theme
|
||||||
|
step :update_default_theme
|
||||||
|
step :log_theme_change
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_remote_themes_are_not_allowlisted
|
||||||
|
Theme.allowed_remote_theme_ids.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_theme(params:)
|
||||||
|
Theme.create(
|
||||||
|
params.slice(:name, :user_id, :user_selectable, :color_scheme_id, :component),
|
||||||
|
) { |theme| params.theme_fields.to_a.each { |field| theme.set_field(**field.symbolize_keys) } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_default_theme(params:, theme:)
|
||||||
|
theme.set_default! if params.default
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_theme_change(theme:, guardian:)
|
||||||
|
StaffActionLogger.new(guardian.user).log_theme_change(nil, theme)
|
||||||
|
end
|
||||||
|
end
|
50
app/services/themes/destroy.rb
Normal file
50
app/services/themes/destroy.rb
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Destroys a theme and logs the staff action. Related records are destroyed
|
||||||
|
# by ActiveRecord dependent: :destroy. Cannot be used to destroy system themes.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# Themes::Destroy.call(
|
||||||
|
# guardian: guardian,
|
||||||
|
# params: {
|
||||||
|
# id: theme.id,
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
class Themes::Destroy
|
||||||
|
include Service::Base
|
||||||
|
|
||||||
|
# @!method self.call(guardian:, params:)
|
||||||
|
# @param [Guardian] guardian
|
||||||
|
# @param [Hash] params
|
||||||
|
# @option params [Integer] :id The ID of the theme to destroy, must be greater than 0.
|
||||||
|
# @return [Service::Base::Context]
|
||||||
|
|
||||||
|
params do
|
||||||
|
attribute :id, :integer
|
||||||
|
|
||||||
|
# Negative theme IDs are for system themes only, which cannot be destroyed.
|
||||||
|
validates :id, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
model :theme
|
||||||
|
|
||||||
|
transaction do
|
||||||
|
step :destroy_theme
|
||||||
|
step :log_theme_destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_theme(params:)
|
||||||
|
Theme.find_by(id: params.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy_theme(theme:)
|
||||||
|
theme.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_theme_destroy(theme:, guardian:)
|
||||||
|
StaffActionLogger.new(guardian.user).log_theme_destroy(theme)
|
||||||
|
end
|
||||||
|
end
|
50
app/services/themes/get_translations.rb
Normal file
50
app/services/themes/get_translations.rb
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Gets all of the translation overrides for a theme, which are defined
|
||||||
|
# in locale yml files for a theme. A ThemeField is created for each locale,
|
||||||
|
# which in turn creates ThemeTranslationOverride records.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# Themes::GetTranslations.call(
|
||||||
|
# guardian: guardian,
|
||||||
|
# params: {
|
||||||
|
# id: theme.id,
|
||||||
|
# locale: "en",
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
class Themes::GetTranslations
|
||||||
|
include Service::Base
|
||||||
|
|
||||||
|
params do
|
||||||
|
attribute :locale, :string
|
||||||
|
attribute :id, :integer
|
||||||
|
|
||||||
|
validates :id, presence: true
|
||||||
|
validates :locale, presence: true
|
||||||
|
|
||||||
|
validate :validate_locale, if: -> { locale.present? }
|
||||||
|
|
||||||
|
def validate_locale
|
||||||
|
return if I18n.available_locales.include?(locale.to_sym)
|
||||||
|
errors.add(:base, I18n.t("errors.messages.invalid_locale", invalid_locale: locale))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
model :theme
|
||||||
|
model :translations
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_theme(params:)
|
||||||
|
Theme.find_by(id: params.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_translations(theme:, params:)
|
||||||
|
I18n.with_locale(params.locale) do
|
||||||
|
theme.translations.map do |translation|
|
||||||
|
{ key: translation.key, value: translation.value, default: translation.default }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -283,6 +283,7 @@ en:
|
|||||||
format: ! "%{attribute} %{message}"
|
format: ! "%{attribute} %{message}"
|
||||||
format_with_full_message: "<b>%{attribute}</b>: %{message}"
|
format_with_full_message: "<b>%{attribute}</b>: %{message}"
|
||||||
messages:
|
messages:
|
||||||
|
invalid_locale: "%{invalid_locale} is not a valid locale"
|
||||||
too_long_validation:
|
too_long_validation:
|
||||||
one: "is limited to %{count} character; you entered %{length}."
|
one: "is limited to %{count} character; you entered %{length}."
|
||||||
other: "is limited to %{count} characters; you entered %{length}."
|
other: "is limited to %{count} characters; you entered %{length}."
|
||||||
@ -312,6 +313,7 @@ en:
|
|||||||
less_than_or_equal_to: must be less than or equal to %{count}
|
less_than_or_equal_to: must be less than or equal to %{count}
|
||||||
not_a_number: is not a number
|
not_a_number: is not a number
|
||||||
not_an_integer: must be an integer
|
not_an_integer: must be an integer
|
||||||
|
must_all_be_positive: must all be positive
|
||||||
odd: must be odd
|
odd: must be odd
|
||||||
record_invalid: ! "Validation failed: %{errors}"
|
record_invalid: ! "Validation failed: %{errors}"
|
||||||
max_emojis: "can't have more than %{max_emojis_count} emoji"
|
max_emojis: "can't have more than %{max_emojis_count} emoji"
|
||||||
|
@ -573,7 +573,7 @@ RSpec.describe Admin::ThemesController do
|
|||||||
context "when logged in as an admin" do
|
context "when logged in as an admin" do
|
||||||
before { sign_in(admin) }
|
before { sign_in(admin) }
|
||||||
|
|
||||||
it "creates a theme" do
|
it "creates a theme and theme fields" do
|
||||||
post "/admin/themes.json",
|
post "/admin/themes.json",
|
||||||
params: {
|
params: {
|
||||||
theme: {
|
theme: {
|
||||||
@ -589,6 +589,51 @@ RSpec.describe Admin::ThemesController do
|
|||||||
expect(json["theme"]["theme_fields"].length).to eq(1)
|
expect(json["theme"]["theme_fields"].length).to eq(1)
|
||||||
expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
|
expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "can set a theme to default" do
|
||||||
|
post "/admin/themes.json", params: { theme: { name: "my test name", default: "true" } }
|
||||||
|
|
||||||
|
expect(response.status).to eq(201)
|
||||||
|
|
||||||
|
json = response.parsed_body
|
||||||
|
expect(json["theme"]["default"]).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when creating a theme field with an invalid target" do
|
||||||
|
it "errors" do
|
||||||
|
post "/admin/themes.json",
|
||||||
|
params: {
|
||||||
|
theme: {
|
||||||
|
name: "my test name",
|
||||||
|
theme_fields: [name: "scss", target: "blah", value: "body{color: red;}"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
|
||||||
|
json = response.parsed_body
|
||||||
|
expect(json["errors"]).to include("Unknown target blah passed to set field")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when creating a theme field with an invalid type" do
|
||||||
|
it "errors" do
|
||||||
|
post "/admin/themes.json",
|
||||||
|
params: {
|
||||||
|
theme: {
|
||||||
|
name: "my test name",
|
||||||
|
theme_fields: [name: "blahblah", target: "common", value: "body{color: red;}"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
|
||||||
|
json = response.parsed_body
|
||||||
|
expect(json["errors"]).to include(
|
||||||
|
"No type could be guessed for field blahblah for target common",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
shared_examples "theme creation not allowed" do
|
shared_examples "theme creation not allowed" do
|
||||||
@ -619,6 +664,20 @@ RSpec.describe Admin::ThemesController do
|
|||||||
|
|
||||||
include_examples "theme creation not allowed"
|
include_examples "theme creation not allowed"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context "when theme allowlist mode is enabled" do
|
||||||
|
before do
|
||||||
|
global_setting :allowed_theme_repos, " https://magic.com/repo.git, https://x.com/git"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "prevents theme creation with 404 error" do
|
||||||
|
expect do
|
||||||
|
post "/admin/themes.json", params: { theme: { name: "my test name" } }
|
||||||
|
end.not_to change { Theme.count }
|
||||||
|
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#update" do
|
describe "#update" do
|
||||||
@ -1066,7 +1125,7 @@ RSpec.describe Admin::ThemesController do
|
|||||||
it "returns the right response when an invalid id is given" do
|
it "returns the right response when an invalid id is given" do
|
||||||
delete "/admin/themes/9999.json"
|
delete "/admin/themes/9999.json"
|
||||||
|
|
||||||
expect(response.status).to eq(400)
|
expect(response.status).to eq(404)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "deletes the field's javascript cache" do
|
it "deletes the field's javascript cache" do
|
||||||
@ -1401,7 +1460,7 @@ RSpec.describe Admin::ThemesController do
|
|||||||
get "/admin/themes/#{theme.id}/translations/foo.json"
|
get "/admin/themes/#{theme.id}/translations/foo.json"
|
||||||
expect(response.status).to eq(400)
|
expect(response.status).to eq(400)
|
||||||
expect(response.parsed_body["errors"]).to include(
|
expect(response.parsed_body["errors"]).to include(
|
||||||
I18n.t("invalid_params", message: :locale),
|
I18n.t("errors.messages.invalid_locale", invalid_locale: "foo"),
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -1438,19 +1497,24 @@ RSpec.describe Admin::ThemesController do
|
|||||||
expect do
|
expect do
|
||||||
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
|
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
|
||||||
end.to change { Theme.count }.by(-2)
|
end.to change { Theme.count }.by(-2)
|
||||||
|
expect(response.status).to eq(204)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not destroy if any theme is system" do
|
it "does not destroy any themes if any of them is a system theme" do
|
||||||
theme.update_columns(id: -10)
|
theme.update_columns(id: -10)
|
||||||
expect do
|
expect do
|
||||||
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
|
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
|
||||||
end.to change { Theme.count }.by(-1)
|
end.not_to change { Theme.count }
|
||||||
expect { theme_2.reload }.to raise_exception(ActiveRecord::RecordNotFound)
|
expect(response.status).to eq(400)
|
||||||
|
expect(response.parsed_body["errors"]).to eq(
|
||||||
|
["Theme ids " + I18n.t("errors.messages.must_all_be_positive")],
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "logs the theme destroy action for each theme" do
|
it "logs the theme destroy action for each theme" do
|
||||||
StaffActionLogger.any_instance.expects(:log_theme_destroy).twice
|
StaffActionLogger.any_instance.expects(:log_theme_destroy).twice
|
||||||
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
|
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
|
||||||
|
expect(response.status).to eq(204)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
54
spec/services/themes/bulk_destroy_spec.rb
Normal file
54
spec/services/themes/bulk_destroy_spec.rb
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe Themes::BulkDestroy do
|
||||||
|
describe described_class::Contract, type: :model do
|
||||||
|
it { is_expected.to validate_presence_of(:theme_ids) }
|
||||||
|
it { is_expected.to allow_values([1], (1..50).to_a).for(:theme_ids) }
|
||||||
|
it { is_expected.not_to allow_values([], (1..51).to_a).for(:theme_ids) }
|
||||||
|
it do
|
||||||
|
is_expected.not_to allow_values([1, 0, -3]).for(:theme_ids).with_message(
|
||||||
|
/must all be positive/,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".call" do
|
||||||
|
subject(:result) { described_class.call(params:, **dependencies) }
|
||||||
|
|
||||||
|
fab!(:theme_1) { Fabricate(:theme) }
|
||||||
|
fab!(:theme_2) { Fabricate(:theme) }
|
||||||
|
fab!(:admin)
|
||||||
|
|
||||||
|
let(:params) { { theme_ids: [theme_1.id, theme_2.id] } }
|
||||||
|
let(:dependencies) { { guardian: admin.guardian } }
|
||||||
|
|
||||||
|
context "when data is invalid" do
|
||||||
|
let(:params) { {} }
|
||||||
|
|
||||||
|
it { is_expected.to fail_a_contract }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a theme does not exist" do
|
||||||
|
before do
|
||||||
|
theme_1.destroy!
|
||||||
|
theme_2.destroy!
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to fail_to_find_a_model(:themes) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when everything is ok" do
|
||||||
|
it { is_expected.to run_successfully }
|
||||||
|
|
||||||
|
it "destroys the themes" do
|
||||||
|
expect { result }.to change { Theme.count }.by(-2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "logs the theme destroys" do
|
||||||
|
expect_any_instance_of(StaffActionLogger).to receive(:log_theme_destroy).with(theme_1).once
|
||||||
|
expect_any_instance_of(StaffActionLogger).to receive(:log_theme_destroy).with(theme_2).once
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
156
spec/services/themes/create_spec.rb
Normal file
156
spec/services/themes/create_spec.rb
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe Themes::Create do
|
||||||
|
describe described_class::Contract, type: :model do
|
||||||
|
it { is_expected.to validate_presence_of(:name) }
|
||||||
|
it { is_expected.to validate_presence_of(:user_id) }
|
||||||
|
it { is_expected.to validate_length_of(:theme_fields).as_array.is_at_least(0).is_at_most(100) }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".call" do
|
||||||
|
subject(:result) { described_class.call(params:, **dependencies) }
|
||||||
|
|
||||||
|
fab!(:user)
|
||||||
|
fab!(:admin)
|
||||||
|
fab!(:guardian) { admin.guardian }
|
||||||
|
fab!(:color_scheme)
|
||||||
|
|
||||||
|
let(:dependencies) { { guardian: } }
|
||||||
|
|
||||||
|
let(:theme_params) do
|
||||||
|
{
|
||||||
|
name: "My Cool Theme",
|
||||||
|
user_id: admin.id,
|
||||||
|
user_selectable: true,
|
||||||
|
color_scheme_id: color_scheme.id,
|
||||||
|
component: false,
|
||||||
|
theme_fields: [
|
||||||
|
{
|
||||||
|
name: "header",
|
||||||
|
target: "common",
|
||||||
|
value: "header content",
|
||||||
|
type_id: ThemeField.types[:html],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:params) { theme_params }
|
||||||
|
|
||||||
|
context "when remote themes are allowlisted" do
|
||||||
|
before do
|
||||||
|
GlobalSetting.stubs(:allowed_theme_repos).returns(
|
||||||
|
"https://github.com/discourse/sample-theme",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to fail_a_policy(:ensure_remote_themes_are_not_allowlisted) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid theme field target" do
|
||||||
|
let(:params) do
|
||||||
|
theme_params.merge(
|
||||||
|
theme_fields: [
|
||||||
|
{ name: "header", target: "invalid_target", value: "header content", type: "html" },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to fail_to_find_a_model(:theme) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid theme field type" do
|
||||||
|
let(:params) do
|
||||||
|
theme_params.merge(
|
||||||
|
theme_fields: [
|
||||||
|
{
|
||||||
|
name: "header",
|
||||||
|
target: "common",
|
||||||
|
value: "header content",
|
||||||
|
type: "blah", # Invalid type
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to fail_to_find_a_model(:theme) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with invalid component model parameters" do
|
||||||
|
let(:params) { theme_params.merge(component: true) }
|
||||||
|
|
||||||
|
it "fails to create a component" do
|
||||||
|
expect(result).to fail_with_an_invalid_model(:theme)
|
||||||
|
expect(result.theme.errors.full_messages).to eq(
|
||||||
|
[
|
||||||
|
"Theme components can't have color palettes",
|
||||||
|
"Theme components can't be user-selectable",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when everything is ok" do
|
||||||
|
it { is_expected.to run_successfully }
|
||||||
|
|
||||||
|
it "creates a theme with the provided parameters" do
|
||||||
|
expect(result.theme).to have_attributes(
|
||||||
|
name: "My Cool Theme",
|
||||||
|
user_id: admin.id,
|
||||||
|
user_selectable: true,
|
||||||
|
color_scheme_id: color_scheme.id,
|
||||||
|
component: false,
|
||||||
|
theme_fields: [have_attributes(name: "header", value: "header content")],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "logs the theme change" do
|
||||||
|
expect_any_instance_of(StaffActionLogger).to receive(:log_theme_change).with(
|
||||||
|
nil,
|
||||||
|
an_instance_of(Theme),
|
||||||
|
)
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with component param" do
|
||||||
|
let(:params) do
|
||||||
|
theme_params.merge(component: true, user_selectable: false, color_scheme_id: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "creates a component" do
|
||||||
|
expect(result.theme).to be_a_component
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with empty theme_fields" do
|
||||||
|
let(:params) { theme_params.except(:theme_fields) }
|
||||||
|
|
||||||
|
it "creates a theme without fields" do
|
||||||
|
expect(result.theme.theme_fields).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when default param is true" do
|
||||||
|
let(:params) { theme_params.merge(default: true) }
|
||||||
|
|
||||||
|
it "sets the theme as default" do
|
||||||
|
expect(result.theme).to be_default
|
||||||
|
expect(SiteSetting.default_theme_id).to eq(result.theme.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when there is an existing default theme" do
|
||||||
|
fab!(:existing_default) { Fabricate(:theme) }
|
||||||
|
|
||||||
|
before { existing_default.set_default! }
|
||||||
|
|
||||||
|
it "clears the existing default theme" do
|
||||||
|
expect { result }.to change { existing_default.reload.default? }.to(false)
|
||||||
|
expect(result.theme).to be_default
|
||||||
|
expect(SiteSetting.default_theme_id).to eq(result.theme.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
44
spec/services/themes/destroy_spec.rb
Normal file
44
spec/services/themes/destroy_spec.rb
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe Themes::Destroy do
|
||||||
|
describe described_class::Contract, type: :model do
|
||||||
|
it { is_expected.to validate_presence_of(:id) }
|
||||||
|
it { is_expected.to allow_values(1, "1", 42).for(:id) }
|
||||||
|
it { is_expected.not_to allow_values(-1, 0).for(:id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".call" do
|
||||||
|
subject(:result) { described_class.call(params:, **dependencies) }
|
||||||
|
|
||||||
|
fab!(:theme)
|
||||||
|
fab!(:admin)
|
||||||
|
|
||||||
|
let(:params) { { id: theme.id } }
|
||||||
|
let(:dependencies) { { guardian: admin.guardian } }
|
||||||
|
|
||||||
|
context "when data is invalid" do
|
||||||
|
let(:params) { {} }
|
||||||
|
|
||||||
|
it { is_expected.to fail_a_contract }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "for invalid theme id" do
|
||||||
|
before { theme.destroy! }
|
||||||
|
|
||||||
|
it { is_expected.to fail_to_find_a_model(:theme) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when everything is ok" do
|
||||||
|
it { is_expected.to run_successfully }
|
||||||
|
|
||||||
|
it "destroys the theme" do
|
||||||
|
expect { result }.to change { Theme.find_by(id: theme.id) }.to(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "logs the theme destroy" do
|
||||||
|
expect_any_instance_of(StaffActionLogger).to receive(:log_theme_destroy).with(theme)
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
77
spec/services/themes/get_translations_spec.rb
Normal file
77
spec/services/themes/get_translations_spec.rb
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe Themes::GetTranslations do
|
||||||
|
describe described_class::Contract, type: :model do
|
||||||
|
it { is_expected.to validate_presence_of :id }
|
||||||
|
it { is_expected.to validate_presence_of :locale }
|
||||||
|
|
||||||
|
context "when locale is invalid" do
|
||||||
|
let(:locale) { "invalid_locale" }
|
||||||
|
|
||||||
|
it "invalidates the contract" do
|
||||||
|
contract = described_class.new(locale: locale)
|
||||||
|
contract.validate
|
||||||
|
expect(contract.errors.full_messages).to include(
|
||||||
|
I18n.t("errors.messages.invalid_locale", invalid_locale: locale),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".call" do
|
||||||
|
subject(:result) { described_class.call(params:) }
|
||||||
|
|
||||||
|
fab!(:theme)
|
||||||
|
fab!(:locale_field_1) do
|
||||||
|
ThemeField.create!(
|
||||||
|
theme_id: theme.id,
|
||||||
|
name: "en",
|
||||||
|
type_id: ThemeField.types[:yaml],
|
||||||
|
target_id: Theme.targets[:translations],
|
||||||
|
value: <<~YAML,
|
||||||
|
en:
|
||||||
|
theme_metadata:
|
||||||
|
description: "Description of my theme"
|
||||||
|
skip_to_main_content: "Skip to main contentzz"
|
||||||
|
skip_user_nav: "Skip to profile contentzz"
|
||||||
|
YAML
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:locale) { I18n.available_locales.first.to_s }
|
||||||
|
let(:params) { { id: theme.id, locale: locale } }
|
||||||
|
|
||||||
|
context "when data is invalid" do
|
||||||
|
let(:params) { {} }
|
||||||
|
|
||||||
|
it { is_expected.to fail_a_contract }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when theme doesn't exist" do
|
||||||
|
before { theme.destroy! }
|
||||||
|
|
||||||
|
it { is_expected.to fail_to_find_a_model(:theme) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when everything is ok" do
|
||||||
|
it { is_expected.to run_successfully }
|
||||||
|
|
||||||
|
it "returns the theme translations" do
|
||||||
|
expect(result.translations).to eq(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: "skip_to_main_content",
|
||||||
|
value: "Skip to main contentzz",
|
||||||
|
default: "Skip to main contentzz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "skip_user_nav",
|
||||||
|
value: "Skip to profile contentzz",
|
||||||
|
default: "Skip to profile contentzz",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
x
Reference in New Issue
Block a user