diff --git a/app/controllers/api/v1/accounts/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/bulk_actions_controller.rb index 34db47861..1b8babbc9 100644 --- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb @@ -1,13 +1,11 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController - before_action :type_matches? - def create - if type_matches? - ::BulkActionsJob.perform_later( - account: @current_account, - user: current_user, - params: permitted_params - ) + case normalized_type + when 'Conversation' + enqueue_conversation_job + head :ok + when 'Contact' + enqueue_contact_job head :ok else render json: { success: false }, status: :unprocessable_entity @@ -16,11 +14,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll private - def type_matches? - ['Conversation'].include?(params[:type]) + def normalized_type + params[:type].to_s.camelize end - def permitted_params + def enqueue_conversation_job + ::BulkActionsJob.perform_later( + account: @current_account, + user: current_user, + params: conversation_params + ) + end + + def enqueue_contact_job + Contacts::BulkActionJob.perform_later( + @current_account.id, + current_user.id, + contact_params + ) + end + + def conversation_params params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) end + + def contact_params + params.require(:ids) + permitted = params.permit(:type, ids: [], labels: [add: []]) + permitted[:ids] = permitted[:ids].map(&:to_i) if permitted[:ids].present? + permitted + end end diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index 0e893b767..12bed151d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue'; import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import countries from 'shared/constants/countries'; const props = defineProps({ @@ -20,9 +21,17 @@ const props = defineProps({ availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, + selectable: { type: Boolean, default: false }, + isSelected: { type: Boolean, default: false }, }); -const emit = defineEmits(['toggle', 'updateContact', 'showContact']); +const emit = defineEmits([ + 'toggle', + 'updateContact', + 'showContact', + 'select', + 'avatarHover', +]); const { t } = useI18n(); @@ -88,111 +97,148 @@ const onClickExpand = () => { }; const onClickViewDetails = () => emit('showContact', props.id); + +const toggleSelect = checked => { + emit('select', checked); +}; + +const handleAvatarHover = isHovered => { + emit('avatarHover', isHovered); +}; diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue index 7acc2ff55..72bc2c7dd 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue @@ -10,7 +10,15 @@ import { } from 'shared/helpers/CustomErrors'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; -defineProps({ contacts: { type: Array, required: true } }); +const props = defineProps({ + contacts: { type: Array, required: true }, + selectedContactIds: { + type: Array, + default: () => [], + }, +}); + +const emit = defineEmits(['toggleContact']); const { t } = useI18n(); const store = useStore(); @@ -20,6 +28,9 @@ const route = useRoute(); const uiFlags = useMapGetter('contacts/getUIFlags'); const isUpdating = computed(() => uiFlags.value.isUpdating); const expandedCardId = ref(null); +const hoveredAvatarId = ref(null); + +const selectedIdsSet = computed(() => new Set(props.selectedContactIds || [])); const updateContact = async updatedData => { try { @@ -58,25 +69,43 @@ const onClickViewDetails = async id => { const toggleExpanded = id => { expandedCardId.value = expandedCardId.value === id ? null : id; }; + +const isSelected = id => selectedIdsSet.value.has(id); + +const shouldShowSelection = id => { + return hoveredAvatarId.value === id || isSelected(id); +}; + +const handleSelect = (id, value) => { + emit('toggleContact', { id, value }); +}; + +const handleAvatarHover = (id, isHovered) => { + hoveredAvatarId.value = isHovered ? id : null; +}; diff --git a/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue b/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue index 94cab9a33..b7ee59ead 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue @@ -61,23 +61,26 @@ const bulkCheckboxState = computed({ >
-
+
- + {{ selectAllLabel }}
- + {{ selectedCountLabel }} +
+
-
- +
+
+ +
+ +
diff --git a/app/jobs/contacts/bulk_action_job.rb b/app/jobs/contacts/bulk_action_job.rb new file mode 100644 index 000000000..e05a9541f --- /dev/null +++ b/app/jobs/contacts/bulk_action_job.rb @@ -0,0 +1,14 @@ +class Contacts::BulkActionJob < ApplicationJob + queue_as :medium + + def perform(account_id, user_id, params) + account = Account.find(account_id) + user = User.find(user_id) + + Contacts::BulkActionService.new( + account: account, + user: user, + params: params + ).perform + end +end diff --git a/app/services/contacts/bulk_action_service.rb b/app/services/contacts/bulk_action_service.rb new file mode 100644 index 000000000..c759e95a5 --- /dev/null +++ b/app/services/contacts/bulk_action_service.rb @@ -0,0 +1,32 @@ +class Contacts::BulkActionService + def initialize(account:, user:, params:) + @account = account + @user = user + @params = params.deep_symbolize_keys + end + + def perform + return assign_labels if labels_to_add.any? + + Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}") + { success: false, error: 'unknown_operation' } + end + + private + + def assign_labels + Contacts::BulkAssignLabelsService.new( + account: @account, + contact_ids: ids, + labels: labels_to_add + ).perform + end + + def ids + Array(@params[:ids]).compact + end + + def labels_to_add + @labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?) + end +end diff --git a/app/services/contacts/bulk_assign_labels_service.rb b/app/services/contacts/bulk_assign_labels_service.rb new file mode 100644 index 000000000..4aa4a5de5 --- /dev/null +++ b/app/services/contacts/bulk_assign_labels_service.rb @@ -0,0 +1,19 @@ +class Contacts::BulkAssignLabelsService + def initialize(account:, contact_ids:, labels:) + @account = account + @contact_ids = Array(contact_ids) + @labels = Array(labels).compact_blank + end + + def perform + return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank? + + contacts = @account.contacts.where(id: @contact_ids) + + contacts.find_each do |contact| + contact.add_labels(@labels) + end + + { success: true, updated_contact_ids: contacts.pluck(:id) } + end +end diff --git a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb index ec6af61be..9c53099fd 100644 --- a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb @@ -195,6 +195,37 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer') expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer') end + + it 'enqueues contact bulk action job with permitted params' do + contact_one = create(:contact, account: account) + contact_two = create(:contact, account: account) + + previous_adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :test + + expect do + post "/api/v1/accounts/#{account.id}/bulk_actions", + headers: agent.create_new_auth_token, + params: { + type: 'Contact', + ids: [contact_one.id, contact_two.id], + labels: { add: %w[vip support] }, + extra: 'ignored' + } + end.to have_enqueued_job(Contacts::BulkActionJob).with( + account.id, + agent.id, + hash_including( + 'ids' => [contact_one.id, contact_two.id], + 'labels' => hash_including('add' => %w[vip support]) + ) + ) + + expect(response).to have_http_status(:success) + ensure + ActiveJob::Base.queue_adapter = previous_adapter + clear_enqueued_jobs + end end end diff --git a/spec/jobs/contacts/bulk_action_job_spec.rb b/spec/jobs/contacts/bulk_action_job_spec.rb new file mode 100644 index 000000000..12a727000 --- /dev/null +++ b/spec/jobs/contacts/bulk_action_job_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe Contacts::BulkActionJob, type: :job do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:params) { { 'ids' => [1], 'labels' => { 'add' => ['vip'] } } } + + it 'invokes the bulk action service with account and user' do + service_instance = instance_double(Contacts::BulkActionService, perform: true) + + allow(Contacts::BulkActionService).to receive(:new).and_return(service_instance) + + described_class.perform_now(account.id, user.id, params) + + expect(Contacts::BulkActionService).to have_received(:new).with( + account: account, + user: user, + params: params + ) + expect(service_instance).to have_received(:perform) + end +end diff --git a/spec/services/contacts/bulk_assign_labels_service_spec.rb b/spec/services/contacts/bulk_assign_labels_service_spec.rb new file mode 100644 index 000000000..3ae27a81b --- /dev/null +++ b/spec/services/contacts/bulk_assign_labels_service_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +RSpec.describe Contacts::BulkAssignLabelsService do + subject(:service) do + described_class.new( + account: account, + contact_ids: [contact_one.id, contact_two.id, other_contact.id], + labels: labels + ) + end + + let(:account) { create(:account) } + let!(:contact_one) { create(:contact, account: account) } + let!(:contact_two) { create(:contact, account: account) } + let!(:other_contact) { create(:contact) } + let(:labels) { %w[vip support] } + + it 'assigns labels to the contacts that belong to the account' do + service.perform + + expect(contact_one.reload.label_list).to include(*labels) + expect(contact_two.reload.label_list).to include(*labels) + end + + it 'does not assign labels to contacts outside the account' do + service.perform + + expect(other_contact.reload.label_list).to be_empty + end + + it 'returns ids of contacts that were updated' do + result = service.perform + + expect(result[:success]).to be(true) + expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id) + end + + it 'returns success with no updates when labels are blank' do + result = described_class.new( + account: account, + contact_ids: [contact_one.id], + labels: [] + ).perform + + expect(result).to eq(success: true, updated_contact_ids: []) + expect(contact_one.reload.label_list).to be_empty + end +end