discourse/app/controllers/categories_controller.rb
Martin Brennan 1ce5697430
UX: Add or remove category types with a dropdown (#39477)
The current UX of removing category types ("Remove X type" button
in the relevant tab like Support/Ideas is not ideal, nor
is the way of adding more category types (hunting down the
right setting and enabling it).

This PR solves both problems by adding a multi-select dropdown
to the General tab of the category edit page, which lists all available
category types and allows adding or removing them in one place.

On our hosting, certain category types will be gated by plan
level.
2026-04-30 11:38:50 +10:00

838 lines
27 KiB
Ruby

# frozen_string_literal: true
class CategoriesController < ApplicationController
include TopicQueryParams
requires_login except: %i[
index
categories_and_latest
categories_and_top
categories_and_hot
show
redirect
find_by_slug
visible_groups
find
search
]
before_action :fetch_category, only: %i[show update destroy visible_groups]
before_action :initialize_staff_action_logger, only: %i[create update destroy]
skip_before_action :check_xhr,
only: %i[
index
categories_and_latest
categories_and_top
categories_and_hot
redirect
]
skip_before_action :verify_authenticity_token, only: %i[search]
# The front-end is POSTing data to this endpoint, but we're not modifying anything
allow_in_readonly_mode :search
SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR = 1.5
MIN_CATEGORIES_TOPICS = 5
MAX_CATEGORIES_TOPICS = 100
MAX_CATEGORIES_LIMIT = 25
def redirect
return if handle_permalink("/category/#{params[:path]}")
redirect_to path("/c/#{params[:path]}")
end
def index
discourse_expires_in 1.minute
@category_list = fetch_category_list
respond_to do |format|
format.html do
@title =
if current_homepage == "categories" && SiteSetting.short_site_description.present?
"#{SiteSetting.title} - #{SiteSetting.short_site_description}"
elsif current_homepage != "categories"
"#{I18n.t("js.filters.categories.title")} - #{SiteSetting.title}"
end
@description = SiteSetting.site_description
store_preloaded(
@category_list.preload_key,
MultiJson.dump(CategoryListSerializer.new(@category_list, scope: guardian)),
)
@topic_list = fetch_topic_list
if @topic_list.present? && @topic_list.topics.present?
store_preloaded(
@topic_list.preload_key,
MultiJson.dump(TopicListSerializer.new(@topic_list, scope: guardian)),
)
end
render
end
format.json { render_serialized(@category_list, CategoryListSerializer) }
end
end
def categories_and_latest
categories_and_topics(:latest)
end
def categories_and_top
categories_and_topics(:top)
end
def categories_and_hot
categories_and_topics(:hot)
end
def move
guardian.ensure_can_create_category!
params.require("category_id")
params.require("position")
if category = Category.find(params["category_id"])
guardian.ensure_can_see!(category)
category.move_to(params["position"].to_i)
render json: success_json
else
render status: :internal_server_error, json: failed_json
end
end
def reorder
guardian.ensure_can_create_category!
params.require(:mapping)
change_requests = MultiJson.load(params[:mapping])
by_category = Hash[change_requests.map { |cat, pos| [Category.find(cat.to_i), pos] }]
unless guardian.is_admin?
unless by_category.keys.all? { |c| guardian.can_see_category? c }
raise Discourse::InvalidAccess
end
end
by_category.each do |cat, pos|
cat.position = pos
cat.save! if cat.will_save_change_to_position?
end
render json: success_json
end
def types
guardian.ensure_can_create_category!
counts_by_type =
Discourse
.cache
.fetch(Categories::TypeRegistry::COUNTS_CACHE_KEY, expires_in: 1.hour) do
Categories::TypeRegistry.counts
end
render json: {
types: Categories::TypeRegistry.list(only_visible: true),
counts: counts_by_type,
}
end
def show
guardian.ensure_can_see!(@category)
if Category.topic_create_allowed(guardian).where(id: @category.id).exists?
@category.permission = CategoryGroup.permission_types[:full]
end
render_serialized(@category, CategorySerializer)
end
MAX_DESCRIPTION_PARAM_LENGTH = 1000
def create
guardian.ensure_can_create!(Category)
position = category_params.delete(:position)
category_type = params[:category_type]
if category_params[:description].present? &&
category_params[:description].size > MAX_DESCRIPTION_PARAM_LENGTH
render json: {
errors: [
I18n.t(
"category.errors.description_too_long",
count: MAX_DESCRIPTION_PARAM_LENGTH,
),
],
},
status: :unprocessable_entity
return
end
@category =
begin
Category.new(required_create_params.merge(user: current_user))
rescue ArgumentError => e
return render json: { errors: [e.message] }, status: :unprocessable_entity
end
if @category.save
@category.move_to(position.to_i) if position
if category_type.present? &&
UpcomingChanges.enabled_for_user?(:enable_simplified_category_creation, current_user)
Categories::Configure.call(
guardian:,
params: {
category_id: @category.id,
category_type:,
site_setting_configuration_values: params[:category_type_site_settings],
category_configuration_values: category_params[:custom_fields],
},
) do |result|
on_failed_policy(:type_is_available) do
return(
render json: {
errors: [
I18n.t(
"category_types.not_available",
type_name: category_type.capitalize,
),
],
},
status: :unprocessable_entity
)
end
end
end
Scheduler::Defer.later "Log staff action create category" do
@staff_action_logger.log_category_creation(@category)
end
render_serialized(@category, CategorySerializer)
else
render_json_error(@category)
end
end
def update
guardian.ensure_can_edit!(@category)
json_result(@category, serializer: CategorySerializer) do |cat|
old_category_params = category_params.dup
cat.move_to(category_params[:position].to_i) if category_params[:position]
category_params.delete(:position)
old_custom_fields = cat.custom_fields.dup
pending_custom_fields = category_params[:custom_fields]
category_params.delete(:custom_fields)
# Handles adding or removing category types registered by plugins
# based on the multi-type selector in the General tab for categories.
if UpcomingChanges.enabled_for_user?(:enable_simplified_category_creation, current_user)
manage_category_types(cat, pending_custom_fields || {})
cat.reload
end
merge_pending_custom_fields!(cat, pending_custom_fields)
# properly null the value so the database constraint doesn't catch us
category_params[:email_in] = nil if category_params[:email_in]&.blank?
category_params[:minimum_required_tags] = 0 if category_params[:minimum_required_tags]&.blank?
old_permissions = cat.permissions_params
old_permissions = { Group[:everyone].name => 1 } if old_permissions.empty?
if result = cat.update(category_params)
Category.preload_user_fields!(guardian, [cat])
Scheduler::Defer.later "Log staff action change category settings" do
@staff_action_logger.log_category_settings_change(
@category,
old_category_params,
old_permissions: old_permissions,
old_custom_fields: old_custom_fields,
)
end
end
DiscourseEvent.trigger(:category_updated, cat) if result
result
end
end
def update_slug
@category = Category.find(params[:category_id].to_i)
guardian.ensure_can_edit!(@category)
custom_slug = params[:slug].to_s
if custom_slug.blank?
error = @category.errors.full_message(:slug, I18n.t("errors.messages.blank"))
render_json_error(error)
elsif @category.update(slug: custom_slug)
render json: success_json
else
render_json_error(@category)
end
end
def set_notifications
category_id = params[:category_id].to_i
notification_level = params[:notification_level].to_i
CategoryUser.set_notification_level_for_category(current_user, notification_level, category_id)
render json:
success_json.merge(
{
indirectly_muted_category_ids:
CategoryUser.indirectly_muted_category_ids(current_user),
},
)
end
def destroy
guardian.ensure_can_delete!(@category)
@category.destroy
Discourse.cache.delete(Categories::TypeRegistry::COUNTS_CACHE_KEY)
Scheduler::Defer.later "Log staff action delete category" do
@staff_action_logger.log_category_deletion(@category)
end
render json: success_json
end
def find_by_slug
params.require(:category_slug)
@category =
Category.includes(:category_setting).find_by_slug_path(params[:category_slug].split("/"))
raise Discourse::NotFound if @category.blank?
if !guardian.can_see?(@category)
if SiteSetting.detailed_404 && group = @category.access_category_via_group
raise Discourse::InvalidAccess.new(
"not in group",
@category,
custom_message: "not_in_group.title_category",
custom_message_params: {
group: group.name,
},
group: group,
)
else
raise Discourse::NotFound
end
end
@category.permission = CategoryGroup.permission_types[:full] if Category
.topic_create_allowed(guardian)
.where(id: @category.id)
.exists?
Category.preload_user_fields!(guardian, [@category])
render_serialized(@category, CategorySerializer)
end
def visible_groups
@guardian.ensure_can_see!(@category)
groups =
if !@category.groups.exists?(id: Group::AUTO_GROUPS[:everyone])
@category.groups.merge(Group.visible_groups(current_user)).pluck("name")
end
render json: success_json.merge(groups: groups || [])
end
def find
categories = []
serializer = params[:include_permissions] ? CategorySerializer : SiteCategorySerializer
if params[:ids].present?
categories = Category.secured(guardian).where(id: params[:ids])
elsif params[:slug_path].present?
category = Category.find_by_slug_path(params[:slug_path].split("/"))
raise Discourse::NotFound if category.blank?
guardian.ensure_can_see!(category)
ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
categories = [*ancestors, category]
elsif params[:slug_path_with_id].present?
category = Category.find_by_slug_path_with_id(params[:slug_path_with_id])
raise Discourse::NotFound if category.blank?
guardian.ensure_can_see!(category)
ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
categories = [*ancestors, category]
end
raise Discourse::NotFound if categories.blank?
Category.preload_user_fields!(guardian, categories)
render_serialized(categories, serializer, root: :categories, scope: guardian)
end
def hierarchical_search
Category::HierarchicalSearch.call(service_params) do
on_success do |categories:|
render_json_dump(
categories: serialize_data(categories, SiteCategorySerializer, scope: guardian),
)
end
on_failed_contract do |contract|
render json: failed_json.merge(errors: contract.errors.full_messages), status: :bad_request
end
on_failure { render json: failed_json, status: :unprocessable_entity }
end
end
def search
term = params[:term].to_s.strip
parent_category_id = params[:parent_category_id].to_i if params[:parent_category_id].present?
include_uncategorized =
(
if params[:include_uncategorized].present?
ActiveModel::Type::Boolean.new.cast(params[:include_uncategorized])
else
true
end
)
if params[:select_category_ids].is_a?(Array)
select_category_ids = params[:select_category_ids].map(&:presence)
end
if params[:reject_category_ids].is_a?(Array)
reject_category_ids = params[:reject_category_ids].map(&:presence)
end
include_subcategories =
if params[:include_subcategories].present?
ActiveModel::Type::Boolean.new.cast(params[:include_subcategories])
else
true
end
include_ancestors =
if params[:include_ancestors].present?
ActiveModel::Type::Boolean.new.cast(params[:include_ancestors])
else
false
end
prioritized_category_id = params[:prioritized_category_id].to_i if params[
:prioritized_category_id
].present?
limit =
(
if params[:limit].present?
params[:limit].to_i.clamp(1, MAX_CATEGORIES_LIMIT)
else
MAX_CATEGORIES_LIMIT
end
)
page = [1, params[:page].to_i].max
categories = Category.secured(guardian)
if term.present? && words = term.split
words.each do |word|
categories =
categories.where(
"#{Category.normalize_sql("name")} ILIKE #{Category.normalize_sql("?")}",
"%#{word}%",
)
end
end
categories =
(
if parent_category_id != -1
categories.where(parent_category_id: parent_category_id)
else
categories.where(parent_category_id: nil)
end
) if parent_category_id.present?
categories =
categories.where.not(id: SiteSetting.uncategorized_category_id) if !include_uncategorized
categories = categories.where(id: select_category_ids) if select_category_ids
categories = categories.where.not(id: reject_category_ids) if reject_category_ids
categories = categories.where(parent_category_id: nil) if !include_subcategories
categories_count = categories.count
categories =
categories
.includes(
:uploaded_logo,
:uploaded_logo_dark,
:uploaded_background,
:uploaded_background_dark,
:tags,
:tag_groups,
:form_templates,
category_required_tag_groups: :tag_group,
)
.joins("LEFT JOIN topics t on t.id = categories.topic_id")
.select("categories.*, t.slug topic_slug")
.order(
"starts_with(#{Category.normalize_sql("categories.name")}, #{Category.normalize_sql(ActiveRecord::Base.connection.quote(term))}) DESC",
"categories.parent_category_id IS NULL DESC",
"categories.id IS NOT DISTINCT FROM #{ActiveRecord::Base.connection.quote(prioritized_category_id)} DESC",
"categories.parent_category_id IS NOT DISTINCT FROM #{ActiveRecord::Base.connection.quote(prioritized_category_id)} DESC",
"categories.id ASC",
)
.limit(limit)
.offset((page - 1) * limit)
if Site.preloaded_category_custom_fields.present?
Category.preload_custom_fields(categories, Site.preloaded_category_custom_fields)
end
Category.preload_user_fields!(guardian, categories)
response = {
categories_count: categories_count,
categories: serialize_data(categories, SiteCategorySerializer, scope: guardian),
}
if include_ancestors
ancestors = Category.secured(guardian).ancestors_of(categories.map(&:id))
Category.preload_user_fields!(guardian, ancestors)
response[:ancestors] = serialize_data(ancestors, SiteCategorySerializer, scope: guardian)
end
render_json_dump(response)
end
private
def merge_pending_custom_fields!(category, pending_custom_fields)
pending_custom_fields&.each do |key, value|
if value.nil? || value == ""
category.custom_fields.delete(key)
else
category.custom_fields[key] = if value.is_a?(TrueClass) || value.is_a?(FalseClass)
value.to_s
else
value
end
end
end
end
def manage_category_types(category, pending_custom_fields)
# NOTE: The code in this block is pretty similar to what we are doing in
# configure_site_settings in Categories::Types::Base, however here we
# need to be able to update site settings across all category types for
# the category and it's best to do this in one query rather than
# multiple.
#
# Maybe in future we do something different, but this is a good starting point.
if params[:category_type_site_settings].present?
category_type_settings =
params[:category_type_site_settings].permit!.to_h.map do |name, value|
{ setting_name: name, value: }
end
# We do this because we want to allow updating hidden settings for the
# category type, but not other settings. The configuration schema for a
# category type defines which settings it wants to change, so that's a
# good source to use as an allowlist here.
allowed_setting_names = category.category_type_site_setting_names
SiteSetting::Update.call(
guardian:,
options: {
allow_changing_hidden: allowed_setting_names,
},
params: {
settings: category_type_settings,
},
)
end
if params.has_key?(:category_types)
# Discussion can never be removed as a category type, so we always add it back.
new_category_types =
Array(params[:category_types]).compact_blank.map(&:to_sym) + [:discussion]
current_category_types = category.category_types.keys
removed_category_types = current_category_types - new_category_types
added_category_types = new_category_types - current_category_types
# Some category custom fields (like
# DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD) control whether
# the type is enabled or not, so we need to remove them from the pending
# custom fields to avoid turning the type back on/off again.
(added_category_types + removed_category_types).each do |category_type|
Categories::TypeRegistry
.get(category_type)
.configuration_schema_keys(:category_custom_fields)
.each { |custom_field_key| pending_custom_fields.delete(custom_field_key) }
end
removed_category_types.each do |category_type|
Categories::Unconfigure.call(
guardian:,
params: {
category_id: category.id,
category_type:,
},
)
end
added_category_types.each do |category_type|
Categories::Configure.call(
guardian:,
params: {
category_id: category.id,
category_type:,
},
) do |result|
on_failed_policy(:type_is_available) do
return(
render json: {
errors: [
I18n.t(
"category_types.not_available",
type_name: category_type.capitalize,
),
],
},
status: :unprocessable_entity
)
end
end
end
end
end
def topics_per_page
return SiteSetting.categories_topics if SiteSetting.categories_topics > 0
count = Category.secured(guardian).where(parent_category: nil).count
count = (SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR * count).to_i
count.clamp(MIN_CATEGORIES_TOPICS, MAX_CATEGORIES_TOPICS)
end
def categories_and_topics(topics_filter)
discourse_expires_in 1.minute
result = CategoryAndTopicLists.new
result.category_list = fetch_category_list
result.topic_list = fetch_topic_list(topics_filter:)
render_serialized(result, CategoryAndTopicListsSerializer, root: false)
end
def required_param_keys
[:name]
end
def required_create_params
required_param_keys.each { |key| params.require(key) }
category_params
end
def category_params
@category_params ||=
begin
if p = params[:permissions]
p.each { |k, v| p[k] = v.to_i }
end
if SiteSetting.tagging_enabled
params[:allowed_tags] = params[:allowed_tags].presence || [] if params[:allowed_tags]
params[:allowed_tag_groups] = params[:allowed_tag_groups].presence || [] if params[
:allowed_tag_groups
]
params[:required_tag_groups] = params[:required_tag_groups].presence || [] if params[
:required_tag_groups
]
end
conditional_param_keys = []
if SiteSetting.enable_category_group_moderation?
conditional_param_keys << { moderating_group_ids: [] }
end
if SiteSetting.content_localization_enabled?
conditional_param_keys << :locale
conditional_param_keys << { category_localizations: %i[id locale name description] }
end
permitted_params = [
*required_param_keys,
:position,
:name,
:color,
:text_color,
:style_type,
:emoji,
:icon,
:email_in,
:email_in_allow_strangers,
:mailinglist_mirror,
:all_topics_wiki,
:allow_unlimited_owner_edits_on_first_post,
:default_slow_mode_seconds,
:parent_category_id,
:auto_close_hours,
:auto_close_based_on_last_post,
:uploaded_logo_id,
:uploaded_logo_dark_id,
:uploaded_background_id,
:uploaded_background_dark_id,
:slug,
:allow_badges,
:topic_template,
:topic_title_placeholder,
:description,
:sort_order,
:sort_ascending,
:topic_featured_link_allowed,
:show_subcategory_list,
:num_featured_topics,
:default_view,
:subcategory_list_style,
:default_top_period,
:minimum_required_tags,
:navigate_to_first_post_after_read,
:search_priority,
:allow_global_tags,
:read_only_banner,
:default_list_filter,
{ topic_posting_review_group_ids: [] },
{ reply_posting_review_group_ids: [] },
*conditional_param_keys,
]
DiscoursePluginRegistry.category_update_param_with_callback.each do |param_name, config|
permitted_params << param_name if config[:plugin].enabled?
end
result =
params.permit(
*permitted_params,
category_setting_attributes: %i[
auto_bump_cooldown_days
num_auto_bump_daily
require_reply_approval
require_topic_approval
nested_replies_default
topic_posting_review_mode
reply_posting_review_mode
],
custom_fields: {
},
permissions: [*p.try(:keys)],
allowed_tags: [],
allowed_tag_groups: [],
required_tag_groups: %i[name min_count],
form_template_ids: [],
)
if result[:required_tag_groups] && !result[:required_tag_groups].is_a?(Array)
raise Discourse::InvalidParameters.new(:required_tag_groups)
end
if @category
DiscoursePluginRegistry.category_update_param_with_callback.each do |param_name, config|
next if !config[:plugin].enabled?
next if !result.key?(param_name)
@category.instance_variable_set(:"@#{param_name}_callback_value", result[param_name])
# remove from params so that AR doesn't try to set it as an attribute
result.delete(param_name)
end
end
result
end
end
def fetch_category
@category = Category.find_by_slug(params[:id]) || Category.find_by(id: params[:id].to_i)
raise Discourse::NotFound if @category.blank?
end
def fetch_category_list
parent_category =
if params[:parent_category_id].present?
Category.find_by_slug(params[:parent_category_id]) ||
Category.find_by(id: params[:parent_category_id].to_i)
elsif params[:category_slug_path_with_id].present?
Category.find_by_slug_path_with_id(params[:category_slug_path_with_id])
end
include_topics =
params[:include_topics] ||
(parent_category && parent_category.subcategory_list_includes_topics?) ||
SiteSetting.desktop_category_page_style == "categories_with_featured_topics" ||
SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" ||
SiteSetting.desktop_category_page_style == "categories_boxes_with_topics" ||
SiteSetting.desktop_category_page_style == "categories_with_top_topics" ||
SiteSetting.mobile_category_page_style == "categories_with_featured_topics" ||
SiteSetting.mobile_category_page_style == "categories_boxes_with_topics" ||
SiteSetting.mobile_category_page_style == "subcategories_with_featured_topics"
include_subcategories =
SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" ||
SiteSetting.mobile_category_page_style == "subcategories_with_featured_topics" ||
params[:include_subcategories] == "true"
category_options = {
is_homepage: current_homepage == "categories",
parent_category_id: parent_category&.id,
include_topics: include_topics,
include_subcategories: include_subcategories,
tag: params[:tag],
page: params[:page].try(:to_i) || 1,
}
@category_list = CategoryList.new(guardian, category_options)
end
def fetch_topic_list(topics_filter: nil)
style =
if topics_filter
"categories_and_#{topics_filter}_topics"
else
SiteSetting.desktop_category_page_style
end
topic_options = { per_page: topics_per_page, no_definitions: true }
topic_options.merge!(build_topic_list_options)
topic_options[:order] = "created" if SiteSetting.desktop_category_page_style ==
"categories_and_latest_topics_created_date"
case style
when "categories_and_latest_topics", "categories_and_latest_topics_created_date"
@topic_list = TopicQuery.new(current_user, topic_options).list_latest
@topic_list.more_topics_url = url_for(latest_path(sort: topic_options[:order]))
when "categories_and_top_topics"
@topic_list =
TopicQuery.new(current_user, topic_options).list_top_for(
SiteSetting.top_page_default_timeframe.to_sym,
)
@topic_list.more_topics_url = url_for(top_path)
when "categories_and_hot_topics"
@topic_list = TopicQuery.new(current_user, topic_options).list_hot
@topic_list.more_topics_url = url_for(hot_path)
end
@topic_list
end
def initialize_staff_action_logger
@staff_action_logger = StaffActionLogger.new(current_user)
end
end