discourse/app/controllers/drafts_controller.rb
Keegan George ad21ae98ff
FEATURE: Bulk select posts and delete drafts (#34972)
## 🔍 Overview

This PR adds bulk selection functionality to the PostList component and
implements optimized bulk deletion for the drafts page. Users can now
select multiple drafts and delete them all at once with a single network
request, significantly improving performance and user experience.

The implementation includes:
- A new reusable bulk selection system for PostList components
- Optimized bulk delete endpoint that reduces network requests by 90%
- Comprehensive bulk controls UI with select all/clear all functionality
- Shift+click range selection similar to topic lists
- Complete test coverage for all new functionality

##  More details

**Bulk Selection System**

The PostList component now supports optional bulk selection through
these new parameters:

- `@bulkSelectEnabled={{true}}` - Shows checkboxes next to each post
- `@bulkSelectHelper={{helper}}` - Manages selection state (use
`PostBulkSelectHelper`)
- `@bulkActions={{actions}}` - Array of bulk action objects for the
dropdown menu

**Usage Example:**
```gjs
import Component from "@glimmer/component";
import { action } from "@ember/object";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import PostBulkSelectHelper from "discourse/lib/post-bulk-select-helper";

export default class MyComponent extends Component {
  bulkSelectHelper = new PostBulkSelectHelper(this);

  constructor() {
    super(...arguments);
    // Initial updatePosts call
    this.updateBulkSelectPosts();
  }

  @action
  updateBulkSelectPosts() {
    if (this.shouldEnableBulkSelect && this.args.posts) {
      this.bulkSelectHelper.updatePosts(this.args.posts);
    }
  }

  get showBulkSelectHelper() {
    return this.shouldEnableBulkSelect ? this.bulkSelectHelper : null;
  }

  get bulkActions() {
    return [
      {
        label: "delete_selected",
        icon: "trash-can", 
        action: this.handleBulkDelete,
        class: "btn-danger"
      }
    ];
  }

  <template>
    <PostList 
      @posts={{@posts}}
      @bulkSelectEnabled={{this.shouldEnableBulkSelect}}
      @bulkSelectHelper={{this.showBulkSelectHelper}}
      @bulkActions={{this.bulkActions}}
      {{didUpdate this.updateBulkSelectPosts @posts}}
    />
  </template>
}
```

**Performance Optimization**

The drafts page now uses a new bulk delete endpoint (`DELETE
/drafts/bulk_destroy`) that:
- Processes multiple drafts in a single HTTP request instead of N
individual requests
- Uses database transactions for atomic operations (all-or-nothing)  
- Reduces database queries from 2N to 2 total queries
- Validates draft sequences upfront to fail fast on conflicts

**Technical Implementation**

- `PostBulkSelectHelper`: New helper class for managing selection state
with support for individual selection, range selection (shift+click),
and bulk operations with reactive posts tracking
- `PostListBulkControls`: New component providing selection count,
select all/clear all buttons, and bulk actions dropdown
- Enhanced PostList and PostListItem components with conditional bulk
selection UI
- Updated user-stream component to use optimized bulk deletion with
automatic selection cleanup
- Comprehensive styling with responsive design

**API Changes**

- New controller action: `DraftsController#bulk_destroy`
- New route: `DELETE /drafts/bulk_destroy`
- New JavaScript method: `Draft.bulkClear(drafts)`
- Enhanced `PostBulkSelectHelper` with `updatePosts()` method for
reactive data updates
- Fully backward compatible - existing single delete functionality
unchanged

**Testing**

- 9 new controller specs covering bulk deletion edge cases, validation,
and API access
- 11 integration tests for PostList bulk selection functionality  
- 10 system specs for end-to-end drafts page bulk selection workflows
- All existing tests continue to pass

## 📹 Screen Recording


https://github.com/user-attachments/assets/2d5a9b38-f1cb-43ee-88ac-285b71083612
2025-09-29 12:47:54 -07:00

222 lines
6.6 KiB
Ruby

# frozen_string_literal: true
class DraftsController < ApplicationController
requires_login
skip_before_action :check_xhr, :preload_json
INDEX_LIMIT = 50
def index
params.permit(:offset)
stream =
Draft.stream(
user: current_user,
offset: params[:offset],
limit: fetch_limit_from_params(default: nil, max: INDEX_LIMIT),
)
response = { drafts: serialize_data(stream, DraftSerializer) }
if guardian.can_lazy_load_categories?
category_ids = stream.map { |draft| draft.topic&.category_id }.compact.uniq
categories = Category.secured(guardian).with_parents(category_ids)
response[:categories] = serialize_data(categories, CategoryBadgeSerializer)
end
render json: response
end
def show
raise Discourse::NotFound.new if params[:id].blank?
seq = params[:sequence] || DraftSequence.current(current_user, params[:id])
render json: { draft: Draft.get(current_user, params[:id], seq), draft_sequence: seq }
end
def create
raise Discourse::NotFound.new if params[:draft_key].blank?
if params[:data].size > SiteSetting.max_draft_length
raise Discourse::InvalidParameters.new(:data)
end
begin
data = JSON.parse(params[:data])
rescue JSON::ParserError
raise Discourse::InvalidParameters.new(:data)
end
if reached_max_drafts_per_user?(params)
render_json_error I18n.t("draft.too_many_drafts.title"),
status: 403,
extras: {
description:
I18n.t(
"draft.too_many_drafts.description",
base_url: Discourse.base_url,
),
}
return
end
sequence =
begin
Draft.set(
current_user,
params[:draft_key],
params[:sequence].to_i,
params[:data],
params[:owner],
force_save: params[:force_save],
)
rescue Draft::OutOfSequence
begin
if !Draft.exists?(user_id: current_user.id, draft_key: params[:draft_key])
Draft.set(
current_user,
params[:draft_key],
DraftSequence.current(current_user, params[:draft_key]),
params[:data],
params[:owner],
)
else
raise Draft::OutOfSequence
end
rescue Draft::OutOfSequence
render_json_error I18n.t("draft.sequence_conflict_error.title"),
status: 409,
extras: {
description: I18n.t("draft.sequence_conflict_error.description"),
}
return
end
end
json = success_json.merge(draft_sequence: sequence)
# check for conflicts when editing a post
if data.present? && data["postId"].present? && data["action"].to_s.start_with?("edit")
original_text = data["original_text"] || data["originalText"]
original_title = data["original_title"]
original_tags = data["original_tags"]
if original_text.present?
if post = Post.find_by(id: data["postId"])
conflict = original_text != post.raw
if post.post_number == 1
conflict ||= original_title.present? && original_title != post.topic.title
# Since the topic might have hidden tags the current editor can't see,
# we need to check for conflicts even though there might not be any visible tags in the editor
if !conflict
original_tags = (original_tags || []).map(&:downcase).to_set
current_tags = post.topic.tags.pluck(:name).to_set
hidden_tags = DiscourseTagging.hidden_tag_names(@guardian).to_set
conflict = original_tags != (current_tags - hidden_tags)
end
end
if conflict
conflict_user = BasicUserSerializer.new(post.last_editor, root: false)
json.merge!(conflict_user:)
end
end
end
end
render json: json
end
def destroy
user =
if is_api?
if @guardian.is_admin?
fetch_user_from_params
else
raise Discourse::InvalidAccess
end
else
current_user
end
begin
Draft.clear(user, params[:id], params[:sequence].to_i)
rescue Draft::OutOfSequence
# nothing really we can do here, if try clearing a draft that is not ours, just skip it.
# rendering an error causes issues in the composer
rescue StandardError => e
return render json: failed_json.merge(errors: e), status: 401
end
render json: success_json
end
def bulk_destroy
params.require(:draft_keys)
draft_keys = params[:draft_keys]
sequences = params[:sequences] || {}
return render json: success_json.merge(deleted_count: 0) if draft_keys.empty?
user =
if is_api?
if @guardian.is_admin?
fetch_user_from_params
else
raise Discourse::InvalidAccess
end
else
current_user
end
# Validate all sequences first (fail fast)
sequence_errors = []
draft_keys.each do |draft_key|
begin
current_sequence = DraftSequence.current(user, draft_key)
provided_sequence = sequences[draft_key].to_i
sequence_errors << draft_key if provided_sequence != current_sequence
rescue StandardError
# If we can't get sequence for some reason, skip validation for this draft
# This maintains the same lenient behavior as the single delete
end
end
if sequence_errors.any?
render json:
failed_json.merge(
errors: "Draft sequence conflict for keys: #{sequence_errors.join(", ")}",
),
status: 409
return
end
# Bulk delete in single transaction
deleted_count = 0
begin
ActiveRecord::Base.transaction do
deleted_count = Draft.where(user_id: user.id, draft_key: draft_keys).destroy_all.length
# Update user draft count
UserStat.update_draft_count(user.id)
end
rescue StandardError => e
return render json: failed_json.merge(errors: e.message), status: 500
end
render json: success_json.merge(deleted_count: deleted_count)
end
private
def reached_max_drafts_per_user?(params)
user_id = current_user.id
Draft.where(user_id: user_id).count >= SiteSetting.max_drafts_per_user &&
!Draft.exists?(user_id: user_id, draft_key: params[:draft_key])
end
end