Files
chatwoot/spec/builders/v2/reports/drilldown_builder_spec.rb
6a7ca9dd3b feat: Add report bar drilldown drawer (#14626)
## Description

Adds drilldown support for report bar charts powered by
`ReportContainer`. Clicking a non-zero report bar now opens a right-side
drawer with the conversations or messages that contributed to that
bucket, with each row linking to the underlying conversation and message
rows linking with `messageId`.

This includes a new `GET /api/v2/accounts/:account_id/reports/drilldown`
endpoint, backend drilldown builders/serializers, generic chart click
emission, local drawer state via `useReportDrilldown`, compact drilldown
cards, pagination, stale-response protection, and validation for
unsupported drilldown dimensions.

Fixes # CW-4497

https://linear.app/chatwoot/issue/CW-4497/drill-down-on-agent-conversations-report

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Ran the focused backend and frontend checks for the drilldown endpoint,
builder, chart click handling, drawer/card UI, API helper, and
stale-response handling.

Here are the screenshots on how it looks like:
<img width="1792" height="1199" alt="Screenshot 2026-06-02 at 11 32
11 PM"
src="https://github.com/user-attachments/assets/6bdb8832-b9df-4bf3-9a2a-beaefe203b6e"
/>
<img width="1791" height="1230" alt="Screenshot 2026-06-02 at 11 32
34 PM"
src="https://github.com/user-attachments/assets/36e92eb7-3208-4855-87f4-0c7f316df54d"
/>
<img width="1784" height="1235" alt="Screenshot 2026-06-02 at 11 32
46 PM"
src="https://github.com/user-attachments/assets/f7a53916-74f2-4622-9305-042e0ac9e877"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-07-02 16:07:26 +05:30

231 lines
11 KiB
Ruby

require 'rails_helper'
RSpec.describe V2::Reports::DrilldownBuilder do
subject(:drilldown) { described_class.new(account, params).build }
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:current_time) { Time.zone.parse('2026-05-20 12:00') }
let(:bucket_start) { current_time.beginning_of_day }
let(:bucket_end) { bucket_start + 1.day }
let(:metric) { 'conversations_count' }
let(:params) do
{
metric: metric,
type: filter_type,
id: filter_id,
since: bucket_start.to_i.to_s,
until: bucket_end.to_i.to_s,
bucket_timestamp: bucket_start.to_i.to_s,
group_by: 'day',
timezone_offset: '0',
business_hours: false
}
end
let(:filter_type) { :account }
let(:filter_id) { nil }
before do
travel_to current_time
end
describe '#build' do
context 'with conversation count metric' do
it 'returns conversations created in the clicked bucket' do
conversation = create(
:conversation,
account: account,
inbox: inbox,
created_at: bucket_start + 2.hours,
last_activity_at: bucket_start + 4.hours
)
last_message = create(
:message,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :incoming,
content: 'Latest customer note',
created_at: bucket_start + 3.hours
)
conversation.update!(last_activity_at: bucket_start + 4.hours)
create(:conversation, account: account, inbox: inbox, created_at: bucket_start - 1.hour)
expect(drilldown[:meta]).to include(metric: 'conversations_count', record_type: 'conversation', total_count: 1)
expect(drilldown[:meta][:bucket]).to eq({ since: bucket_start.to_i, until: bucket_end.to_i })
expect(drilldown[:payload].first[:conversation][:display_id]).to eq(conversation.display_id)
expect(drilldown[:payload].first[:conversation][:created_at]).to eq(
(bucket_start + 2.hours).to_i
)
expect(drilldown[:payload].first[:conversation][:last_activity_at]).to eq(
(bucket_start + 4.hours).to_i
)
expect(drilldown[:payload].first[:conversation][:last_message][:id]).to eq(last_message.id)
expect(drilldown[:payload].first[:conversation][:last_message][:content]).to eq('Latest customer note')
end
it 'loads latest messages in one query for the page conversations' do
first_conversation = create(:conversation, account: account, inbox: inbox, created_at: bucket_start + 2.hours)
second_conversation = create(:conversation, account: account, inbox: inbox, created_at: bucket_start + 3.hours)
first_message = create(:message, account: account, inbox: inbox, conversation: first_conversation, created_at: bucket_start + 4.hours)
second_message = create(:message, account: account, inbox: inbox, conversation: second_conversation, created_at: bucket_start + 5.hours)
message_queries = []
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, _started, _finished, _unique_id, payload|
message_queries << payload[:sql] if payload[:sql].match?(/\ASELECT .*FROM "messages"/m) && !payload[:cached]
end
payload = drilldown[:payload]
expect(payload.map { |row| row[:conversation][:last_message][:id] }).to contain_exactly(
first_message.id,
second_message.id
)
expect(message_queries.size).to eq(1)
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
context 'when filtering by agent' do
let(:metric) { 'conversations_count' }
let(:filter_type) { :agent }
let(:filter_id) { agent.id }
let(:agent) { create(:user, account: account) }
let(:other_agent) { create(:user, account: account) }
it 'returns only conversations assigned to the selected agent' do
conversation = create(:conversation, account: account, inbox: inbox, assignee: agent, created_at: bucket_start + 2.hours)
create(:conversation, account: account, inbox: inbox, assignee: other_agent, created_at: bucket_start + 3.hours)
expect(drilldown[:meta][:total_count]).to eq(1)
expect(drilldown[:payload].first[:conversation][:id]).to eq(conversation.id)
end
end
end
context 'with message count metric' do
let(:metric) { 'incoming_messages_count' }
it 'returns messages created in the clicked bucket' do
conversation = create(:conversation, account: account, inbox: inbox)
message = create(:message, account: account, inbox: inbox, conversation: conversation,
message_type: :incoming, content: 'Need help', created_at: bucket_start + 1.hour)
create(:message, account: account, inbox: inbox, conversation: conversation,
message_type: :outgoing, created_at: bucket_start + 2.hours)
expect(drilldown[:meta]).to include(record_type: 'message', total_count: 1)
expect(drilldown[:payload].first[:record_type]).to eq('message')
expect(drilldown[:payload].first[:message][:id]).to eq(message.id)
expect(drilldown[:payload].first[:message][:content]).to eq('Need help')
end
end
context 'with first response time metric' do
let(:metric) { 'avg_first_response_time' }
let(:agent) { create(:user, account: account) }
it 'infers the related outgoing message and uses the selected metric value' do
conversation = create(:conversation, account: account, inbox: inbox)
message = create(:message, account: account, inbox: inbox, conversation: conversation,
sender: agent, message_type: :outgoing, created_at: bucket_start + 2.hours)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation, user: agent,
name: 'first_response', value: 120, value_in_business_hours: 45,
created_at: bucket_start + 2.hours, event_end_time: message.created_at)
params[:business_hours] = true
expect(drilldown[:meta]).to include(record_type: 'message', total_count: 1)
expect(drilldown[:payload].first[:record_type]).to eq('message')
expect(drilldown[:payload].first[:message][:id]).to eq(message.id)
expect(drilldown[:payload].first[:metric_value]).to eq(45)
end
it 'loads inferred and latest messages in two queries for the page events' do
first_conversation = create(:conversation, account: account, inbox: inbox)
second_conversation = create(:conversation, account: account, inbox: inbox)
first_message = create(:message, account: account, inbox: inbox, conversation: first_conversation,
sender: agent, message_type: :outgoing, created_at: bucket_start + 2.hours)
second_message = create(:message, account: account, inbox: inbox, conversation: second_conversation,
sender: agent, message_type: :outgoing, created_at: bucket_start + 3.hours)
create(:reporting_event, account: account, inbox: inbox, conversation: first_conversation, user: agent,
name: 'first_response', value: 120, created_at: bucket_start + 2.hours,
event_end_time: first_message.created_at)
create(:reporting_event, account: account, inbox: inbox, conversation: second_conversation, user: agent,
name: 'first_response', value: 90, created_at: bucket_start + 3.hours,
event_end_time: second_message.created_at)
message_queries = []
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, _started, _finished, _unique_id, payload|
message_queries << payload[:sql] if payload[:sql].match?(/\ASELECT .*FROM "messages"/m) && !payload[:cached]
end
payload = drilldown[:payload]
expect(payload.map { |row| row[:message][:id] }).to contain_exactly(
first_message.id,
second_message.id
)
expect(message_queries.size).to eq(2)
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
it 'falls back to the conversation when no matching message is found' do
conversation = create(:conversation, account: account, inbox: inbox)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation, user: agent,
name: 'first_response', value: 120, created_at: bucket_start + 2.hours,
event_end_time: bucket_start + 2.hours)
expect(drilldown[:payload].first[:record_type]).to eq('conversation')
expect(drilldown[:payload].first[:conversation][:id]).to eq(conversation.id)
end
end
context 'with bot handoff count metric' do
let(:metric) { 'bot_handoffs_count' }
it 'returns one row per handoff conversation' do
first_conversation = create(:conversation, account: account, inbox: inbox)
second_conversation = create(:conversation, account: account, inbox: inbox)
create(:reporting_event, account: account, inbox: inbox, conversation: first_conversation,
name: 'conversation_bot_handoff', created_at: bucket_start + 1.hour)
create(:reporting_event, account: account, inbox: inbox, conversation: first_conversation,
name: 'conversation_bot_handoff', created_at: bucket_start + 2.hours)
create(:reporting_event, account: account, inbox: inbox, conversation: second_conversation,
name: 'conversation_bot_handoff', created_at: bucket_start + 3.hours)
expect(drilldown[:meta][:total_count]).to eq(2)
expect(drilldown[:payload].map { |row| row[:conversation][:id] }).to contain_exactly(
first_conversation.id,
second_conversation.id
)
expect(drilldown[:payload].pluck(:event_name)).to all(eq('conversation_bot_handoff'))
end
end
context 'with bot resolution count metric' do
let(:metric) { 'bot_resolutions_count' }
before do
params[:until] = (bucket_start + 2.days).to_i.to_s
end
it 'excludes conversations with handoffs anywhere in the selected report range' do
resolved_conversation = create(:conversation, account: account, inbox: inbox)
handed_off_conversation = create(:conversation, account: account, inbox: inbox)
create(:reporting_event, account: account, inbox: inbox, conversation: resolved_conversation,
name: 'conversation_bot_resolved', created_at: bucket_start + 1.hour)
create(:reporting_event, account: account, inbox: inbox, conversation: handed_off_conversation,
name: 'conversation_bot_resolved', created_at: bucket_start + 2.hours)
create(:reporting_event, account: account, inbox: inbox, conversation: handed_off_conversation,
name: 'conversation_bot_handoff', created_at: bucket_start + 1.day)
expect(drilldown[:meta][:total_count]).to eq(1)
expect(drilldown[:payload].first[:conversation][:id]).to eq(resolved_conversation.id)
end
end
end
end