Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
60210fb
FEATURE: simple mode for dicourse docs
SamSaffron Mar 18, 2026
16835e6
FEATURE: rework simple mode to use post stream filtering
megothss Mar 20, 2026
bab7f6e
move simple mode to upcoming changes.
SamSaffron Mar 30, 2026
0d2938e
lint
SamSaffron Mar 30, 2026
d446c57
UX: Strip doc-categories simple mode topic list to essentials
ZogStriP Mar 31, 2026
4b5c048
FEATURE: Add doc-category index editor
megothss Mar 25, 2026
3643f6e
UX: Improve doc-category index editor UI
megothss Mar 26, 2026
bddaa82
UX: Polish index editor interactions and alignment
megothss Mar 26, 2026
ab1c6e1
UX: Replace index mode select with dropdown menu
megothss Mar 26, 2026
33a2d55
UX: Simplify index mode labels and topic chooser layout
megothss Mar 26, 2026
b55cee1
UX: Rework add menu and auto-index into combo button and dropdown
megothss Mar 26, 2026
64a7b33
UX: Show feedback when no missing topics and remove new items on cancel
megothss Mar 26, 2026
a65da76
UX: Add batch editing mode to `doc-category-index-editor`
megothss Mar 26, 2026
b5147a4
FEATURE: Explicit mode detection via index_topic_id sentinel values
megothss Mar 26, 2026
3ac9c20
FIX: Make doc_index_topic_id reactive and fix mode switching dirtying
megothss Mar 26, 2026
bf2807b
DEV: Simplify handling of `doc_index_topic_id` and index sections
megothss Mar 26, 2026
31d78b3
UX: Confirm disabling category index when data is present
megothss Mar 26, 2026
bc04c1b
UX: Add confirm dialog for switching to direct index mode
megothss Mar 26, 2026
24297d0
UX: Add batch drag-and-drop reordering to index editor
megothss Mar 26, 2026
3042d06
UX: Improve drop target handling and align checkboxes with drag handles
megothss Mar 26, 2026
ad8bded
FEATURE: Add validation to index editor save and apply
megothss Mar 26, 2026
3f6e5f7
FEATURE: Smart topic link titles and inline validation errors
megothss Mar 27, 2026
7a87be1
UX: Rename "Visual editor" to "Editor" in sidebar index modes
megothss Mar 27, 2026
77cdb25
FIX: Prevent editor willDestroy from overwriting disabled mode form data
megothss Mar 27, 2026
4008434
FIX: Harden index editor save and mode switching
megothss Apr 6, 2026
f203da3
FIX: Validate index limits and clean up serializer
megothss Apr 6, 2026
6635d19
FIX: Code review fixes for index editor
megothss Apr 6, 2026
ee74388
DEV: Update tests for code review fixes
megothss Apr 6, 2026
959e18f
FIX: Second round of code review fixes
megothss Apr 6, 2026
1c73af1
DEV: Split doc-category-index-editor into separate files
megothss Apr 6, 2026
6ac7e88
FIX: Code review fixes for index editor (round 3)
megothss Apr 6, 2026
ee4d168
FEATURE: Add auto-index section for direct mode
megothss Apr 7, 2026
0d845f2
FEATURE: Extend index editor for legacy category settings
megothss Apr 7, 2026
0c4a72a
DEV: Allow empty title on first sidebar section in doc-categories editor
megothss Apr 8, 2026
ca1092f
DEV: Rename _docIndexSections to _docIndexEditorState and document fo…
megothss Apr 8, 2026
6c93b07
UX: Disambiguate auto-title and auto-indexed badges in index editor
megothss Apr 8, 2026
2e87ef0
UX: Improve batch mode toolbar and controls
megothss Apr 8, 2026
0d4468c
DEV: Update model annotations and fix spec formatting
megothss Apr 8, 2026
4dd8ded
DEV: Fix legacy index editor system specs
megothss Apr 8, 2026
3878e0e
DEV: Auto-index topics on archetype and visibility changes
megothss Apr 8, 2026
9f59a71
DEV: Add missing auto-index specs and fix subcategory guard
megothss Apr 8, 2026
a25bbf6
UX: Add include subcategories toggle to auto-index section
megothss Apr 8, 2026
c01d620
UX: Add resync toggle and refine auto-index badge dropdown
megothss Apr 8, 2026
e3f1da6
DEV: Add tests for auto-index subcategory toggle and resync
megothss Apr 8, 2026
54ccc5d
DEV: Refactor services to use Discourse Service::Base pattern
megothss Apr 8, 2026
af5efad
DEV: Refactor frontend for production readiness
megothss Apr 9, 2026
fea1090
FEATURE: Gate index editor behind upcoming change
megothss Apr 9, 2026
36b8b04
PERF: Cap AutoIndexer::Sync queries to avoid unbounded topic loading
megothss Apr 9, 2026
3426769
PERF: Reduce `MAX_TOPICS` in `DocCategories::IndexesController` to 50
megothss Apr 9, 2026
d511578
PERF: Batch-load topics and guard publish in AutoIndexer::Sync
megothss Apr 9, 2026
a1c6a69
FIX: Transaction safety, null safety, and missing translation
megothss Apr 9, 2026
9e74dc6
DEV: Backend cleanup — preload topics, remove dead code, rename step
megothss Apr 9, 2026
21ba5e6
DEV: Frontend cleanup — a11y, dead code, CSS, and locale fixes
megothss Apr 9, 2026
141c394
FIX: State management bugs in index editor bulk operations
megothss Apr 9, 2026
42466be
FIX: Auto-index ancestry check and category change cleanup
megothss Apr 9, 2026
e17f222
DEV: Fix migration rollback, deduplicate category IDs, use delete_all
megothss Apr 9, 2026
7dcf309
DEV: Add missing test coverage for auto-index, controller, and valida…
megothss Apr 9, 2026
8ff6b55
Merge origin/main into doc-index-editor-improvements
megothss Apr 9, 2026
0ba3ae2
FIX: Autotracking backtracking assertion on editingCount
megothss Apr 10, 2026
871d380
FIX: Skip blank doc_index_sections callback and enable editor setting…
megothss Apr 10, 2026
c1b69b1
UX: Rename default auto-index section title to "Other topics"
megothss Apr 10, 2026
3b448b6
UX: Update `impact` setting in doc categories for clearer admin control
megothss Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions app/controllers/doc_categories/indexes_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module ::DocCategories
class IndexesController < ::ApplicationController
requires_plugin PLUGIN_NAME
before_action :ensure_admin
before_action :ensure_index_editor_enabled

MAX_TOPICS = 50

def topics
category = ::Category.find_by(id: params[:category_id])
raise Discourse::NotFound if category.blank?

include_subcategories = ActiveRecord::Type::Boolean.new.cast(params[:include_subcategories])

topic_query =
TopicQuery.new(current_user, { limit: false, no_subcategories: !include_subcategories })

topic_ids = topic_query.list_category_topic_ids(category)

visible_scope = Topic.where(id: topic_ids, visible: true).order(:title)
total_count = visible_scope.count

topics =
visible_scope
.limit(MAX_TOPICS)
.pluck(:id, :title, :slug)
.map { |id, title, slug| { id:, title:, slug: } }

render json: { topics:, total_count: }
end

def update
DocCategories::IndexSaver.call(service_params) do
on_success do |index_structure:|
render json: success_json.merge(index_structure: index_structure)
end
on_model_not_found(:category) { raise Discourse::NotFound }
on_failed_policy(:not_topic_managed) do
raise Discourse::InvalidAccess.new(
"index managed by a topic",
nil,
custom_message: "doc_categories.errors.index_topic_managed",
)
end
on_failed_step(:parse_and_validate_sections) do |step|
render json: failed_json.merge(errors: [step.error]), status: :bad_request
end
on_failure { render json: failed_json, status: :unprocessable_entity }
end
end

private

def ensure_index_editor_enabled
return if SiteSetting.doc_categories_index_editor

# Allow edits to categories already in direct mode
index = DocCategories::Index.find_by(category_id: params[:category_id])
raise Discourse::InvalidAccess if index.nil? || !index.mode_direct?
end
end
end
27 changes: 27 additions & 0 deletions app/jobs/regular/doc_categories_auto_index.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Jobs
class DocCategoriesAutoIndex < ::Jobs::Base
def execute(args)
action = args[:action]
raise Discourse::InvalidParameters.new(:action) if action.blank?

case action
when "add"
topic_id = args[:topic_id]
raise Discourse::InvalidParameters.new(:topic_id) if topic_id.blank?
DocCategories::AutoIndexer::AddTopic.call(params: { topic_id: topic_id })
when "remove"
topic_id = args[:topic_id]
raise Discourse::InvalidParameters.new(:topic_id) if topic_id.blank?
DocCategories::AutoIndexer::RemoveTopic.call(params: { topic_id: topic_id })
when "sync"
index_id = args[:index_id]
raise Discourse::InvalidParameters.new(:index_id) if index_id.blank?
DocCategories::AutoIndexer::Sync.call(params: { index_id: index_id })
else
raise Discourse::InvalidParameters.new(:action)
end
end
end
end
2 changes: 1 addition & 1 deletion app/jobs/regular/doc_categories_refresh_index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def execute(args)
category_id = args[:category_id]
raise Discourse::InvalidParameters.new(:category_id) if category_id.blank?

DocCategories::IndexStructureRefresher.new(category_id).refresh!
DocCategories::IndexStructureRefresher.call(params: { category_id: category_id })
end
end
end
73 changes: 60 additions & 13 deletions app/models/doc_categories/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ module DocCategories
class Index < ActiveRecord::Base
self.table_name = "doc_categories_indexes"

# Sentinel value for index_topic_id indicating visual editor (direct) mode.
# NULL = no index configured (MODE_NONE), -1 = visual editor (MODE_DIRECT),
# positive integer = topic-based index (MODE_TOPIC).
INDEX_TOPIC_ID_DIRECT = -1
MAX_SECTIONS = 50
MAX_LINKS_PER_SECTION = 200

belongs_to :category, class_name: "::Category"
belongs_to :index_topic, class_name: "::Topic"
belongs_to :index_topic, class_name: "::Topic", optional: true

has_many :sidebar_sections,
-> { order(:position) },
Expand All @@ -15,9 +22,21 @@ class Index < ActiveRecord::Base
dependent: :destroy

validates :category_id, presence: true, uniqueness: true
validates :index_topic_id, presence: true, uniqueness: true
validates :index_topic_id, uniqueness: true, allow_nil: true, unless: :mode_direct?

validate :index_topic_matches_category, if: :mode_topic?

def mode_none?
index_topic_id.nil?
end

validate :index_topic_matches_category
def mode_direct?
index_topic_id == INDEX_TOPIC_ID_DIRECT
end

def mode_topic?
index_topic_id.present? && index_topic_id > 0
end

def sidebar_structure
sidebar_sections
Expand All @@ -28,21 +47,48 @@ def sidebar_structure
# for text: always use link[:title] if present, otherwise use topic title if topic is valid
# for href: always use link[:href] if present, otherwise use topic relative_url if topic is valid

topic = valid_topic(topic)
topic = valid_topic(link.topic)

text = link.title.presence || (topic&.title)
href = link.href.presence || (topic&.relative_url)
next if text.blank? || href.blank?
{ text:, href: }
result = { text:, href: }
result[:icon] = link.icon if link.icon.present?
if link.topic_id.present?
result[:topic_id] = link.topic_id
result[:topic_title] = topic&.title
result[:custom_title] = link.title.present?
end
result[:auto_indexed] = true if link.auto_indexed?
result
end

next if links.blank?
next if links.blank? && !section.auto_index?

{ text: section.title, links: links }
section_result = { id: section.id, text: section.title, links: links }
section_result[:auto_index] = true if section.auto_index?
section_result
end
.compact
end

def auto_index_section
sidebar_sections.find_by(auto_index: true)
end

def auto_index_enabled?
mode_direct? && auto_index_section.present?
end

# Returns an array of category IDs to source topics from for auto-indexing.
def matching_category_ids
if auto_index_include_subcategories
::Category.subcategory_ids(category_id)
else
[category_id]
end
end

def valid_topic(topic)
return nil if topic.blank?
return nil if topic.private_message?
Expand All @@ -65,14 +111,15 @@ def index_topic_matches_category
#
# Table name: doc_categories_indexes
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# category_id :bigint not null
# index_topic_id :bigint not null
# id :bigint not null, primary key
# auto_index_include_subcategories :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# category_id :bigint not null
# index_topic_id :bigint
#
# Indexes
#
# idx_doc_categories_indexes_on_category_id (category_id) UNIQUE
# idx_doc_categories_indexes_on_index_topic_id (index_topic_id) UNIQUE
# idx_doc_categories_indexes_on_index_topic_id (index_topic_id) UNIQUE WHERE ((index_topic_id IS NOT NULL) AND (index_topic_id <> '-1'::integer))
#
4 changes: 4 additions & 0 deletions app/models/doc_categories/sidebar_link.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class SidebarLink < ActiveRecord::Base

belongs_to :topic, optional: true

scope :auto_indexed, -> { where(auto_indexed: true) }

before_validation :populate_href_from_topic, if: -> { topic.present? && href.blank? }

validates :sidebar_section_id, presence: true
Expand Down Expand Up @@ -47,7 +49,9 @@ def populate_href_from_topic
# Table name: doc_categories_sidebar_links
#
# id :bigint not null, primary key
# auto_indexed :boolean default(FALSE), not null
# href :text not null
# icon :string(100)
# position :integer not null
# title :string
# created_at :datetime not null
Expand Down
9 changes: 9 additions & 0 deletions app/models/doc_categories/sidebar_section.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ class SidebarSection < ActiveRecord::Base
validates :index_id, presence: true
validates :position, presence: true, uniqueness: { scope: :index_id }
validates :title, length: { maximum: 255 }, allow_blank: true
validate :only_one_auto_index_section_per_index, if: :auto_index?

private

def only_one_auto_index_section_per_index
existing = self.class.where(index_id: index_id, auto_index: true).where.not(id: id).exists?
errors.add(:auto_index, "only one auto-index section allowed per index") if existing
end
end
end

Expand All @@ -27,6 +35,7 @@ class SidebarSection < ActiveRecord::Base
# Table name: doc_categories_sidebar_sections
#
# id :bigint not null, primary key
# auto_index :boolean default(FALSE), not null
# position :integer not null
# title :string
# created_at :datetime not null
Expand Down
78 changes: 78 additions & 0 deletions app/services/doc_categories/auto_indexer/add_topic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

module DocCategories
module AutoIndexer
class AddTopic
include Service::Base

MAX_LINKS_PER_SECTION = DocCategories::Index::MAX_LINKS_PER_SECTION

params do
attribute :topic_id, :integer

validates :topic_id, presence: true
end

model :topic
policy :topic_qualifies
step :find_matching_indexes

transaction { step :create_links }

step :publish_changes

private

def fetch_topic(params:)
::Topic.find_by(id: params.topic_id)
end

def topic_qualifies(topic:)
!topic.trashed? && topic.visible? && topic.archetype == Archetype.default
end

def find_matching_indexes(topic:)
context[:matching_indexes] = DocCategories::Index
.joins(:sidebar_sections)
.where(sidebar_sections: { auto_index: true })
.where(index_topic_id: DocCategories::Index::INDEX_TOPIC_ID_DIRECT)
.distinct
.select { |index| index.matching_category_ids.include?(topic.category_id) }
end

def create_links(matching_indexes:, topic:)
context[:affected_indexes] = []

matching_indexes.each do |index|
section = index.auto_index_section
next if section.nil?
next if topic_already_linked?(index, topic)
next if section.sidebar_links.count >= MAX_LINKS_PER_SECTION

next_position = (section.sidebar_links.maximum(:position) || -1) + 1
section.sidebar_links.create!(
topic_id: topic.id,
href: topic.relative_url,
position: next_position,
auto_indexed: true,
)
context[:affected_indexes] << index
end
end

def publish_changes(affected_indexes:)
return if affected_indexes.blank?

Site.clear_cache
affected_indexes.each { |index| index.category.publish_category }
end

def topic_already_linked?(index, topic)
DocCategories::SidebarLink
.joins(:sidebar_section)
.where(sidebar_section: { index_id: index.id })
.exists?(topic_id: topic.id)
end
end
end
end
45 changes: 45 additions & 0 deletions app/services/doc_categories/auto_indexer/remove_topic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module DocCategories
module AutoIndexer
class RemoveTopic
include Service::Base

params do
attribute :topic_id, :integer

validates :topic_id, presence: true
end

step :find_auto_indexed_links

transaction { step :destroy_links }

step :publish_changes

private

def find_auto_indexed_links(params:)
context[:auto_indexed_links] = DocCategories::SidebarLink
.auto_indexed
.joins(sidebar_section: :index)
.where(topic_id: params.topic_id)
.includes(sidebar_section: { index: :category })
end

def destroy_links(auto_indexed_links:)
context[:affected_categories] = auto_indexed_links
.filter_map { |link| link.sidebar_section.index.category }
.uniq
auto_indexed_links.destroy_all
end

def publish_changes(affected_categories:)
return if affected_categories.blank?

Site.clear_cache
affected_categories.each(&:publish_category)
end
end
end
end
Loading