discourse/spec/system/user_activity_drafts_spec.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

181 lines
5.1 KiB
Ruby

# frozen_string_literal: true
describe "User activity drafts", type: :system do
fab!(:user)
let(:drafts_page) { PageObjects::Pages::UserActivityDrafts.new }
before { sign_in(user) }
describe "bulk selection functionality" do
before do
# Create some drafts for testing
Draft.set(
user,
"#{Draft::NEW_TOPIC}_1",
0,
{ title: "Draft topic 1", reply: "First draft content" }.to_json,
)
Draft.set(
user,
"#{Draft::NEW_TOPIC}_2",
0,
{ title: "Draft topic 2", reply: "Second draft content" }.to_json,
)
Draft.set(
user,
"#{Draft::NEW_TOPIC}_3",
0,
{ title: "Draft topic 3", reply: "Third draft content" }.to_json,
)
end
it "shows bulk select checkboxes for drafts" do
drafts_page.visit(user)
expect(drafts_page).to have_drafts(count: 3)
expect(drafts_page).to have_bulk_select_checkboxes
end
it "shows bulk controls only after selecting items" do
drafts_page.visit(user)
# Initially no bulk controls should be visible
expect(drafts_page).to have_no_bulk_controls
# Select a draft
drafts_page.select_draft(0)
# Now bulk controls should be visible
expect(drafts_page).to have_bulk_controls
expect(drafts_page).to have_selected_count(1)
end
it "updates selection count when selecting multiple drafts" do
drafts_page.visit(user)
# Select first draft
drafts_page.select_draft(0)
expect(drafts_page).to have_selected_count(1)
# Select second draft
drafts_page.select_draft(1)
expect(drafts_page).to have_selected_count(2)
# Select third draft
drafts_page.select_draft(2)
expect(drafts_page).to have_selected_count(3)
end
it "selects all drafts with select all button" do
drafts_page.visit(user)
# Select one draft to show controls
drafts_page.select_draft(0)
expect(drafts_page).to have_bulk_controls
# Click select all
drafts_page.click_bulk_select_all
expect(drafts_page).to have_selected_count(3)
# All checkboxes should be checked
expect(drafts_page).to have_checkbox_checked(0)
expect(drafts_page).to have_checkbox_checked(1)
expect(drafts_page).to have_checkbox_checked(2)
end
it "clears all selections with clear all button" do
drafts_page.visit(user)
# Select all drafts
drafts_page.select_draft(0)
drafts_page.select_all_drafts
# Clear all selections
drafts_page.clear_all_selections
# No checkboxes should be checked and controls should be hidden
expect(drafts_page).to have_checkbox_unchecked(0)
expect(drafts_page).to have_checkbox_unchecked(1)
expect(drafts_page).to have_checkbox_unchecked(2)
expect(drafts_page).to have_no_bulk_controls
end
it "shows selected styling on selected items" do
drafts_page.visit(user)
# Select first draft
drafts_page.select_draft(0)
# First item should have selected styling, second should not
expect(drafts_page).to have_checkbox_checked(0)
expect(drafts_page).to have_checkbox_unchecked(1)
end
it "toggles selection when clicking checkbox" do
drafts_page.visit(user)
# Initially unchecked
expect(drafts_page).to have_checkbox_unchecked(0)
# Click to select
drafts_page.select_draft(0)
expect(drafts_page).to have_checkbox_checked(0)
expect(drafts_page).to have_bulk_controls
# Click to deselect
drafts_page.select_draft(0)
expect(drafts_page).to have_checkbox_unchecked(0)
expect(drafts_page).to have_no_bulk_controls
end
it "bulk deletes selected drafts" do
drafts_page.visit(user)
# Select first two drafts
drafts_page.select_draft(0)
drafts_page.select_draft(1)
expect(drafts_page).to have_selected_count(2)
# Bulk delete
drafts_page.click_bulk_delete
# Confirm deletion in modal
page.find(".dialog-footer .btn-danger").click
# Should have only one draft left and no bulk controls
expect(drafts_page).to have_drafts(count: 1)
expect(drafts_page).to have_no_bulk_controls
expect(Draft.count).to eq(1)
end
it "clears selection after successful bulk delete" do
drafts_page.visit(user)
# Select all drafts
drafts_page.select_draft(0)
drafts_page.select_all_drafts
expect(drafts_page).to have_selected_count(3)
# Bulk delete all
drafts_page.click_bulk_delete
# Confirm deletion
page.find(".dialog-footer .btn-danger").click
# Should have no drafts and no controls
expect(drafts_page).to have_no_drafts
expect(drafts_page).to have_no_bulk_controls
expect(Draft.count).to eq(0)
end
end
describe "without bulk selection" do
it "does not show bulk select controls when no drafts exist" do
drafts_page.visit(user)
expect(drafts_page).to have_no_drafts
expect(drafts_page).to have_no_bulk_select_checkboxes
expect(drafts_page).to have_no_bulk_controls
end
end
end