From 2ada713f29b2cf5f864cccd3f74d570e73f6b82f Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 24 Apr 2026 09:13:43 -0700 Subject: [PATCH] feat: Add bulk actions for help center articles (translate, status change, delete) (#14137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://linear.app/chatwoot/issue/CW-6950/support-bulk-actions-for-publish-archive-move-to-draft-delete-in How to test 1. Go to Help Center → Articles 2. Select articles using checkboxes → bulk bar appears 3. Click Publish/Draft/Archive → articles update, list refreshes 4. Click Delete → confirmation dialog → articles removed 5. Click Translate (requires Captain enabled) → select locale + category → translation starts 6. Try translating to a locale that already has translations → warning with links to existing articles → "Overwrite and translate" proceeds 8. Single article: click three-dot menu → Translate → same dialog opens for that article https://github.com/user-attachments/assets/7c76495e-f89e-4456-92bd-a6639a9992f4 --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../articles/bulk_actions_controller.rb | 24 ++ .../dashboard/api/helpCenter/articles.js | 14 ++ .../HelpCenter/ArticleCard/ArticleCard.vue | 47 +++- .../Pages/ArticlePage/ArticleList.vue | 29 ++- .../Pages/ArticlePage/ArticlesPage.vue | 231 +++++++++++++++++- .../dashboard/i18n/locale/en/helpCenter.json | 18 ++ .../pages/PortalsArticlesIndexPage.vue | 1 + config/locales/en.yml | 1 + config/routes.rb | 2 + .../articles/bulk_actions_controller_spec.rb | 141 +++++++++++ 10 files changed, 486 insertions(+), 22 deletions(-) create mode 100644 spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb index 584e3dbf2..b45c16828 100644 --- a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb @@ -1,11 +1,31 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController before_action :portal before_action :check_authorization + before_action :set_articles, only: [:update_status, :delete_articles] def translate head :not_implemented end + def update_status + return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? + return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status]) + + ActiveRecord::Base.transaction do + @articles.find_each { |article| article.update!(status: params[:status]) } + end + head :ok + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.message) + end + + def delete_articles + return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? + + @articles.destroy_all + head :ok + end + private def portal @@ -15,5 +35,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba def check_authorization authorize(Article, :create?) end + + def set_articles + @articles = @portal.articles.where(id: params[:ids]) + end end Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController') diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index 781570d0b..c79aa5da7 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -79,6 +79,20 @@ class ArticlesAPI extends PortalsAPI { { ids: articleIds, locale, category_id: categoryId, force } ); } + + bulkUpdateStatus({ portalSlug, articleIds, status }) { + return axios.patch( + `${this.url}/${portalSlug}/articles/bulk_actions/update_status`, + { ids: articleIds, status } + ); + } + + bulkDelete({ portalSlug, articleIds }) { + return axios.delete( + `${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`, + { data: { ids: articleIds } } + ); + } } export default new ArticlesAPI(); diff --git a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue index 25eda255a..93ff15251 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue @@ -17,6 +17,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; const props = defineProps({ id: { @@ -47,9 +48,26 @@ const props = defineProps({ type: Number, required: true, }, + isSelected: { + type: Boolean, + default: false, + }, + selectable: { + type: Boolean, + default: false, + }, + showSelectionControl: { + type: Boolean, + default: false, + }, }); -const emit = defineEmits(['openArticle', 'articleAction']); +const emit = defineEmits([ + 'openArticle', + 'articleAction', + 'toggleSelect', + 'hover', +]); const { t } = useI18n(); @@ -143,14 +161,27 @@ const handleClick = id => { diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue index f1e177505..325f6abe6 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue @@ -1,9 +1,13 @@ + diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index bb9bf2e99..9ae849d25 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -590,6 +590,10 @@ "CATEGORY_PLACEHOLDER": "Select a category", "OPTIONAL": "(optional)", "CONFIRM": "Translate", + "SELECT_ALL": "Select all ({count})", + "SELECTED_COUNT": "{count} selected", + "CLEAR_SELECTION": "Clear selection", + "TRANSLATE_BUTTON": "Translate", "CONFIRM_OVERWRITE": "Overwrite and translate", "DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.", "DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.", @@ -597,6 +601,20 @@ "SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.", "ERROR_MESSAGE": "Failed to start translation. Please try again." } + }, + "BULK_ACTIONS": { + "PUBLISH": "Publish", + "DRAFT": "Draft", + "ARCHIVE": "Archive", + "TRANSLATE": "Translate", + "DELETE": "Delete", + "STATUS_SUCCESS": "Articles updated successfully", + "STATUS_ERROR": "Failed to update articles", + "DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles", + "DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.", + "DELETE_CONFIRM": "Delete", + "DELETE_SUCCESS": "Articles deleted successfully", + "DELETE_ERROR": "Failed to delete articles" } }, "CATEGORY_PAGE": { diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue index 7d636de70..c0fc800e6 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue @@ -119,6 +119,7 @@ watch( :is-category-articles="isCategoryArticles" @page-change="onPageChange" @fetch-portal="fetchPortalAndItsCategories" + @refresh-articles="fetchArticles" /> diff --git a/config/locales/en.yml b/config/locales/en.yml index 36f115bab..9ffd3f3d5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -493,6 +493,7 @@ en: locale_not_available: 'Locale not available in this portal' category_not_found: 'Category not found in this portal' no_articles_found: 'No articles found to process' + invalid_status: 'Invalid status value' send_instructions: email_required: 'Email is required' invalid_email_format: 'Invalid email format' diff --git a/config/routes.rb b/config/routes.rb index c6111d317..a1d3d088e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -361,6 +361,8 @@ Rails.application.routes.draw do namespace :articles do resource :bulk_actions, only: [] do post :translate + patch :update_status + delete :delete_articles end end resources :articles do diff --git a/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb new file mode 100644 index 000000000..3dab5b60f --- /dev/null +++ b/spec/controllers/api/v1/accounts/articles/bulk_actions_controller_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +RSpec.describe 'Article Bulk Actions API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es] }) } + let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') } + let!(:article_one) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) } + let!(:article_two) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) } + let!(:article_three) { create(:article, category: category, portal: portal, account: account, author: admin, status: :published) } + + let(:base_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions" } + + describe 'PATCH articles/bulk_actions/update_status' do + let(:update_status_url) { "#{base_url}/update_status" } + + context 'when unauthenticated' do + it 'returns unauthorized' do + patch update_status_url, params: { ids: [article_one.id], status: 'published' }, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + patch update_status_url, + headers: agent.create_new_auth_token, + params: { ids: [article_one.id], status: 'published' }, + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + it 'publishes multiple articles' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id, article_two.id], status: 'published' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(article_one.reload.status).to eq('published') + expect(article_two.reload.status).to eq('published') + end + + it 'archives multiple articles' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id, article_three.id], status: 'archived' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(article_one.reload.status).to eq('archived') + expect(article_three.reload.status).to eq('archived') + end + + it 'sets articles to draft' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [article_three.id], status: 'draft' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(article_three.reload.status).to eq('draft') + end + + it 'does not affect articles not in the list' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id], status: 'published' }, + as: :json + + expect(article_one.reload.status).to eq('published') + expect(article_three.reload.status).to eq('published') + end + + it 'returns unprocessable entity when no articles found' do + patch update_status_url, + headers: admin.create_new_auth_token, + params: { ids: [0], status: 'published' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe 'DELETE articles/bulk_actions/delete_articles' do + let(:destroy_url) { "#{base_url}/delete_articles" } + + context 'when unauthenticated' do + it 'returns unauthorized' do + delete destroy_url, params: { ids: [article_one.id] }, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + delete destroy_url, + headers: agent.create_new_auth_token, + params: { ids: [article_one.id] }, + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + it 'deletes multiple articles' do + expect do + delete destroy_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id, article_two.id] }, + as: :json + end.to change(Article, :count).by(-2) + + expect(response).to have_http_status(:ok) + end + + it 'does not delete articles not in the list' do + delete destroy_url, + headers: admin.create_new_auth_token, + params: { ids: [article_one.id] }, + as: :json + + expect(Article.exists?(article_one.id)).to be(false) + expect(Article.exists?(article_three.id)).to be(true) + end + + it 'returns unprocessable entity when no articles found' do + delete destroy_url, + headers: admin.create_new_auth_token, + params: { ids: [0] }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end +end