.vbw-planning/milestones/ui-fixes-and-smart-scraping/phases/04-smart-scrape-recommendations/04-PLAN-05.md
Add the ability to select multiple scrape candidate sources and enable scraping for them in bulk. Includes checkboxes on source rows, a confirmation modal with count/warning, and a controller action that updates scraping_enabled on selected sources.
Files:
app/controllers/source_monitor/bulk_scrape_enablements_controller.rbDescription: Create a controller for bulk-enabling scraping on selected sources. Follows CRUD-everything pattern as a standalone resource (not nested under individual source):
module SourceMonitor
class BulkScrapeEnablementsController < ApplicationController
def create
source_ids = Array(params.dig(:bulk_scrape_enablement, :source_ids)).map(&:to_i).reject(&:zero?)
if source_ids.empty?
handle_empty_selection
return
end
sources = Source.where(id: source_ids, scraping_enabled: false)
updated_count = sources.update_all(
scraping_enabled: true,
scraper_adapter: default_adapter,
updated_at: Time.current
)
respond_to do |format|
format.turbo_stream do
responder = SourceMonitor::TurboStreams::StreamResponder.new
responder.toast(
message: "Scraping enabled for #{updated_count} #{'source'.pluralize(updated_count)}.",
level: :success
)
# Redirect to refresh the sources index
responder.redirect(source_monitor.sources_path)
render turbo_stream: responder.render(view_context)
end
format.html do
redirect_to source_monitor.sources_path,
notice: "Scraping enabled for #{updated_count} #{'source'.pluralize(updated_count)}."
end
end
end
private
def default_adapter
SourceMonitor.config.scrapers.default_adapter_name || "readability"
end
def handle_empty_selection
respond_to do |format|
format.turbo_stream do
responder = SourceMonitor::TurboStreams::StreamResponder.new
responder.toast(message: "No sources selected.", level: :warning)
render turbo_stream: responder.render(view_context), status: :unprocessable_entity
end
format.html do
redirect_to source_monitor.sources_path, alert: "No sources selected."
end
end
end
end
end
Tests:
test/controllers/source_monitor/bulk_scrape_enablements_controller_test.rb:
Files:
config/routes.rbDescription: Add top-level resource route (not nested under sources since it operates across multiple sources):
resources :bulk_scrape_enablements, only: :create
Tests:
POST /bulk_scrape_enablements routes to bulk_scrape_enablements#createFiles:
app/views/source_monitor/sources/index.html.erbapp/views/source_monitor/sources/_row.html.erbDescription:
Wrap the sources table in a data-controller="select-all" scope. Add:
<th scope="col" class="w-10 px-3 py-3">
<input type="checkbox"
data-select-all-target="master"
data-action="select-all#toggleAll"
class="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
aria-label="Select all sources">
</th>
_row.html.erb (only for scrape candidates):<td class="w-10 px-3 py-4">
<% if scrape_candidates.include?(source.id) %>
<input type="checkbox"
name="bulk_scrape_enablement[source_ids][]"
value="<%= source.id %>"
data-select-all-target="item"
data-action="select-all#toggleItem"
class="rounded border-slate-300 text-violet-600 focus:ring-violet-500"
aria-label="Select <%= source.name %>">
<% end %>
</td>
bulk-action-bar that shows/hides based on checkbox state, or reuse select-all with a connected bar target. Add a form that submits to bulk_scrape_enablements#create with a confirmation modal:<div data-select-all-target="actionBar" class="hidden sticky bottom-0 border-t border-slate-200 bg-white px-4 py-3 shadow-md">
<div class="flex items-center justify-between">
<span class="text-sm text-slate-700">
<span data-select-all-target="count">0</span> source(s) selected
</span>
<button type="button"
data-action="modal#open"
class="inline-flex items-center rounded-md bg-violet-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-violet-500">
Enable Scraping
</button>
</div>
</div>
Tests:
Files:
app/views/source_monitor/sources/_bulk_scrape_enable_modal.html.erbDescription:
Create a confirmation modal using the existing modal Stimulus controller. The modal shows a warning and submits the bulk enablement form:
<div data-controller="modal" class="relative">
<div data-modal-target="panel" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50" data-action="click->modal#backdrop">
<div class="w-full max-w-md rounded-lg bg-white shadow-xl" data-action="click->modal#stop">
<div class="border-b border-slate-200 px-6 py-4">
<h3 class="text-lg font-semibold text-slate-900">Enable Scraping</h3>
</div>
<div class="px-6 py-4">
<p class="text-sm text-slate-700">
This will enable scraping for the selected sources using the default scraper adapter.
Each source's items will be scraped on their next scheduled run.
</p>
<p class="mt-3 text-sm font-medium text-amber-700">
This action will modify the selected sources' configuration.
</p>
</div>
<div class="flex justify-end gap-3 border-t border-slate-200 px-6 py-4">
<button type="button"
data-action="modal#close"
class="rounded-md border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Cancel
</button>
<button type="submit"
class="rounded-md bg-violet-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-violet-500">
Confirm Enable
</button>
</div>
</div>
</div>
</div>
The modal is wired into the form wrapping the sources table so the submit button posts the checked source IDs.
Tests:
Files:
app/assets/javascripts/source_monitor/controllers/select_all_controller.jsDescription:
Extend the existing select-all controller to support an optional actionBar target and count target. When any checkbox is checked, show the action bar and update the count:
Add to static targets: "actionBar", "count"
Add method updateActionBar():
updateActionBar() {
if (!this.hasActionBarTarget) return;
const checkedCount = this.itemTargets.filter(cb => cb.checked).length;
if (this.hasCountTarget) {
this.countTarget.textContent = checkedCount;
}
if (checkedCount > 0) {
this.actionBarTarget.classList.remove("hidden");
} else {
this.actionBarTarget.classList.add("hidden");
}
}
Call this.updateActionBar() at the end of toggleAll(), toggleItem(), syncMaster(), itemTargetConnected(), and itemTargetDisconnected().
Tests:
yarn build must succeed (ESLint check)