diff --git a/app/builders/v2/reports/drilldown_builder.rb b/app/builders/v2/reports/drilldown_builder.rb new file mode 100644 index 000000000..a3d2c073d --- /dev/null +++ b/app/builders/v2/reports/drilldown_builder.rb @@ -0,0 +1,213 @@ +class V2::Reports::DrilldownBuilder + include DateRangeHelper + include TimezoneHelper + + DEFAULT_GROUP_BY = 'day'.freeze + DEFAULT_PAGE = 1 + DEFAULT_PER_PAGE = 25 + MAX_PER_PAGE = 100 + SUPPORTED_GROUP_BY = %w[hour day week month year].freeze + SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze + MESSAGE_METRICS = { + 'incoming_messages_count' => :incoming, + 'outgoing_messages_count' => :outgoing + }.freeze + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + pattr_initialize :account, :params + + def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s) + + def build + records = paginated_records.to_a + { meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } } + end + + private + + def meta + { + metric: metric, + record_type: record_type, + bucket: { + since: bucket_range.begin.to_i, + until: bucket_range.end.to_i + }, + current_page: current_page, + per_page: per_page, + total_count: paginated_records.total_count, + conversation_count: conversation_count + } + end + + def conversation_count + return paginated_records.total_count if conversation_metric? + + drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id) + end + + def paginated_records + @paginated_records ||= drilldown_scope.page(current_page).per(per_page) + end + + def drilldown_scope + if message_metric? + message_scope + elsif conversation_metric? + conversation_scope + else + reporting_event_scope + end + end + + def message_scope + scope.messages + .where(account_id: account.id, created_at: bucket_range) + .public_send(MESSAGE_METRICS.fetch(metric)) + .includes(:sender, conversation: [:assignee, :contact, :inbox]) + .reorder(created_at: :desc) + end + + def conversation_scope + scope.conversations + .where(account_id: account.id, created_at: bucket_range) + .includes(:assignee, :contact, :inbox) + .order(created_at: :desc) + end + + def reporting_event_scope + events = scope.reporting_events + .where(account_id: account.id, name: raw_event_name, created_at: bucket_range) + .includes(:user, :inbox, conversation: [:assignee, :contact, :inbox]) + .order(created_at: :desc) + + if raw_count_strategy == :exclude_bot_handoffs + events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery) + elsif raw_count_strategy == :distinct_conversation + events = events.where(id: distinct_conversation_event_ids(events)) + end + + events + end + + def bot_handoff_conversation_ids_subquery + scope.reporting_events + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) + end + + def distinct_conversation_event_ids(events) + events.reorder(nil) + .where.not(conversation_id: nil) + .select('MAX(reporting_events.id)') + .group(:conversation_id) + end + + def record_serializer(records) + @record_serializer ||= V2::Reports::DrilldownRecordSerializer.new( + account, + metric, + use_business_hours?, + records + ) + end + + def bucket_range + @bucket_range ||= begin + bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone) + bucket_end = bucket_end_for(bucket_start) + requested_start = Time.zone.at(params[:since].to_i) + requested_end = Time.zone.at(params[:until].to_i) + + [bucket_start, requested_start].max...[bucket_end, requested_end].min + end + end + + def bucket_end_for(bucket_start) + { + 'hour' => bucket_start + 1.hour, + 'day' => bucket_start + 1.day, + 'week' => bucket_start + 1.week, + 'month' => bucket_start + 1.month, + 'year' => bucket_start + 1.year + }.fetch(group_by) + end + + def scope + case dimension_type + when 'account' then account + when 'inbox' then inbox + when 'agent' then user + when 'label' then label + when 'team' then team + else + raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}" + end + end + + def inbox = @inbox ||= account.inboxes.find(params[:id]) + + def user = @user ||= account.users.find(params[:id]) + + def label = @label ||= account.labels.find(params[:id]) + + def team = @team ||= account.teams.find(params[:id]) + + def metric + params[:metric].to_s + end + + def report_metric + @report_metric ||= Reports::ReportMetricRegistry.fetch(metric) + end + + def raw_event_name + report_metric&.raw_event_name + end + + def raw_count_strategy + report_metric&.raw_count_strategy + end + + def record_type + return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric) + + 'conversation' + end + + def message_metric? + MESSAGE_METRICS.key?(metric) + end + + def conversation_metric? + metric == 'conversations_count' + end + + def dimension_type + (params[:type].presence || 'account').to_s + end + + def group_by + @group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY + end + + def timezone + @timezone ||= timezone_name_from_offset(params[:timezone_offset]) + end + + def current_page + [params[:page].to_i, DEFAULT_PAGE].max + end + + def per_page + requested_per_page = params[:per_page].to_i + requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0 + + [requested_per_page, MAX_PER_PAGE].min + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end +end diff --git a/app/builders/v2/reports/drilldown_record_serializer.rb b/app/builders/v2/reports/drilldown_record_serializer.rb new file mode 100644 index 000000000..04edf65b4 --- /dev/null +++ b/app/builders/v2/reports/drilldown_record_serializer.rb @@ -0,0 +1,199 @@ +class V2::Reports::DrilldownRecordSerializer + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + attr_reader :account, :metric, :use_business_hours, :records + + def initialize(account, metric, use_business_hours, records = []) + @account = account + @metric = metric + @use_business_hours = use_business_hours + @records = records + end + + def serialize(record) + return serialize_message(record) if record.is_a?(Message) + return serialize_conversation_event(record) if record.is_a?(ReportingEvent) + + serialize_conversation(record) + end + + private + + def serialize_message(message, metric_value: nil, occurred_at: nil) + { + record_type: 'message', + conversation: conversation_attributes(message.conversation), + message: message_attributes(message), + metric_value: metric_value, + occurred_at: (occurred_at || message.created_at).to_i + } + end + + def serialize_conversation_event(event) + inferred_message = inferred_message_for(event) + if inferred_message.present? + return serialize_message( + inferred_message, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event) + ) + end + + serialize_conversation( + event.conversation, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event), + event_name: event.name + ) + end + + def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil) + serialized_record = { + record_type: 'conversation', + conversation: conversation_attributes(conversation), + message: nil, + metric_value: metric_value, + occurred_at: (occurred_at || conversation&.created_at)&.to_i + } + serialized_record[:event_name] = event_name if event_name.present? + serialized_record + end + + def conversation_attributes(conversation) + return {} if conversation.blank? + + { + id: conversation.id, + display_id: conversation.display_id, + contact_id: conversation.contact_id, + contact_name: conversation.contact&.name, + inbox_id: conversation.inbox_id, + inbox_name: conversation.inbox&.name, + assignee_id: conversation.assignee_id, + assignee_name: conversation.assignee&.name, + status: conversation.status, + created_at: conversation.created_at.to_i, + last_activity_at: conversation.last_activity_at.to_i, + last_message: last_message_attributes(conversation) + } + end + + def message_attributes(message) + { + id: message.id, + content: message.content, + message_type: message.message_type, + sender_name: message.sender&.try(:name), + created_at: message.created_at.to_i + } + end + + def last_message_attributes(conversation) + message = latest_messages_by_conversation_id[conversation.id] + return if message.blank? + + message_attributes(message) + end + + def inferred_message_for(event) + return unless MESSAGE_EVENT_METRICS.include?(metric) + return if event.conversation.blank? || event.event_end_time.blank? + + inferred_messages_by_event_id[event.id] + end + + def first_response_event_with_user?(event) + metric == 'avg_first_response_time' && event.user_id.present? + end + + def message_inference_range(event) + (event.event_end_time - 1.second)..(event.event_end_time + 1.second) + end + + def event_metric_value(event) + use_business_hours ? event.value_in_business_hours : event.value + end + + def event_timestamp(event) + event.event_end_time || event.created_at + end + + def latest_messages_by_conversation_id + @latest_messages_by_conversation_id ||= if conversation_ids.blank? + {} + else + latest_messages.index_by(&:conversation_id) + end + end + + def latest_messages + Message + .where(account_id: account.id, conversation_id: conversation_ids) + .where.not(message_type: :activity) + .select('DISTINCT ON (messages.conversation_id) messages.*') + .reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC')) + .includes(:sender) + end + + def inferred_messages_by_event_id + @inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id| + messages_by_event_id[event.id] = inferred_message_candidates.find do |message| + message_matches_event?(message, event) + end + end + end + + def inferred_message_candidates + @inferred_message_candidates ||= if inference_events.blank? + [] + else + inferred_messages.to_a + end + end + + def inferred_messages + Message + .where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq) + .where(created_at: inference_time_range) + .where(message_type: %i[outgoing template]) + .includes(:sender) + .reorder(created_at: :desc, id: :desc) + end + + def message_matches_event?(message, event) + message.conversation_id == event.conversation_id && + message.created_at.between?( + message_inference_range(event).begin, + message_inference_range(event).end + ) && + message_sender_matches_event?(message, event) + end + + def message_sender_matches_event?(message, event) + return true unless first_response_event_with_user?(event) + + message.sender_id == event.user_id && message.sender_type == 'User' + end + + def inference_time_range + event_end_times = inference_events.map(&:event_end_time) + + (event_end_times.min - 1.second)..(event_end_times.max + 1.second) + end + + def inference_events + @inference_events ||= records.select do |record| + record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present? + end + end + + def conversation_ids + @conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq + end + + def conversation_id_for(record) + return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent) + + record.id + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 192b3619c..93be19eb9 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -51,6 +51,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic') end + def drilldown + return head :unauthorized unless Current.account_user.administrator? + return head :unprocessable_entity unless valid_drilldown_params? + + render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build + end + def conversations return head :unprocessable_entity if params[:type].blank? @@ -133,6 +140,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController }) end + def drilldown_params + permitted_params = params.permit( + :metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page + ).to_h.symbolize_keys + permitted_params.merge( + type: (params[:type].presence || 'account').to_sym, + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + ) + end + + def valid_drilldown_params? + %i[metric bucket_timestamp since until].all? { |param| params[param].present? } && + Reports::ReportMetricRegistry.supported?(params[:metric]) && + V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params) + end + def conversation_params { type: params[:type].to_sym, diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 00f040f8e..daa0cb11d 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient { }); } + getDrilldown({ + metric, + bucketTimestamp, + from, + to, + type = 'account', + id, + groupBy, + businessHours, + page, + perPage, + signal, + }) { + const requestConfig = { + params: { + metric, + bucket_timestamp: bucketTimestamp, + since: from, + until: to, + type, + id, + group_by: groupBy, + business_hours: businessHours, + timezone_offset: getTimeOffset(), + page, + per_page: perPage, + }, + }; + + if (signal) { + requestConfig.signal = signal; + } + + return axios.get(`${this.url}/drilldown`, requestConfig); + } + // eslint-disable-next-line default-param-last getSummary(since, until, type = 'account', id, groupBy, businessHours) { return axios.get(`${this.url}/summary`, { diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index e458633d0..178c98a70 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -1,6 +1,8 @@ import reportsAPI from '../reports'; import ApiClient from '../ApiClient'; +const timezoneOffset = () => -new Date().getTimezoneOffset() / 60; + describe('#Reports API', () => { it('creates correct instance', () => { expect(reportsAPI).toBeInstanceOf(ApiClient); @@ -11,6 +13,7 @@ describe('#Reports API', () => { expect(reportsAPI).toHaveProperty('update'); expect(reportsAPI).toHaveProperty('delete'); expect(reportsAPI).toHaveProperty('getReports'); + expect(reportsAPI).toHaveProperty('getDrilldown'); expect(reportsAPI).toHaveProperty('getSummary'); expect(reportsAPI).toHaveProperty('getAgentReports'); expect(reportsAPI).toHaveProperty('getLabelReports'); @@ -42,11 +45,14 @@ describe('#Reports API', () => { }); expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', { params: { + business_hours: undefined, + group_by: undefined, + id: undefined, metric: 'conversations_count', since: 1621103400, until: 1621621800, type: 'account', - timezone_offset: -0, + timezone_offset: timezoneOffset(), }, }); }); @@ -59,13 +65,70 @@ describe('#Reports API', () => { group_by: undefined, id: undefined, since: 1621103400, - timezone_offset: -0, + timezone_offset: timezoneOffset(), type: 'account', until: 1621621800, }, }); }); + it('#getDrilldown', () => { + reportsAPI.getDrilldown({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + from: 1621103400, + to: 1621621800, + type: 'inbox', + id: 1, + groupBy: 'day', + businessHours: false, + page: 2, + perPage: 25, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', { + params: { + metric: 'incoming_messages_count', + bucket_timestamp: 1621103400, + since: 1621103400, + until: 1621621800, + type: 'inbox', + id: 1, + group_by: 'day', + business_hours: false, + timezone_offset: timezoneOffset(), + page: 2, + per_page: 25, + }, + }); + }); + + it('#getDrilldown with abort signal', () => { + const controller = new AbortController(); + + reportsAPI.getDrilldown({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + signal: controller.signal, + }); + + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', { + params: { + metric: 'incoming_messages_count', + bucket_timestamp: 1621103400, + since: undefined, + until: undefined, + type: 'account', + id: undefined, + group_by: undefined, + business_hours: undefined, + timezone_offset: timezoneOffset(), + page: undefined, + per_page: undefined, + }, + signal: controller.signal, + }); + }); + it('#getAgentReports', () => { reportsAPI.getAgentReports({ from: 1621103400, diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 2ffa0ef11..0171b9620 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -121,6 +121,26 @@ "CLEAR_FILTER": "Clear filter", "EMPTY_LIST": "No results found" }, + "DRILLDOWN": { + "TITLE": "{metric} details", + "RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations", + "RESULT_COUNT_MESSAGE": "{count} message | {count} messages", + "EMPTY": "No records found for this bar.", + "ERROR": "Could not load records. Please try again.", + "ADMIN_ONLY": "Only administrators can drill down into report records.", + "LOAD_MORE": "Load more", + "CLOSE": "Close details", + "PREVIOUS_BUCKET": "Previous bar", + "NEXT_BUCKET": "Next bar", + "UNKNOWN_CONTACT": "Unknown contact", + "UNKNOWN_INBOX": "Unknown inbox", + "UNASSIGNED_AGENT": "Unassigned", + "NO_MESSAGE_CONTENT": "No message content", + "MESSAGE_CREATED_AT": "Message created at {time}", + "EVENT_OCCURRED_AT": "Event occurred at {time}", + "INCOMING_MESSAGE": "Incoming message", + "OUTGOING_MESSAGE": "Outgoing message" + }, "PAGINATION": { "RESULTS": "Showing {start} to {end} of {total} results", "PER_PAGE_TEMPLATE": "{size} / page" diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue index 03b7290d6..2a1cd18c8 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue @@ -101,6 +101,9 @@ export default { summary-fetching-key="getBotSummaryFetchingStatus" :group-by="groupBy" :report-keys="reportKeys" + :from="from" + :to="to" + :business-hours="businessHours" /> diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue index 9794d97e4..ea1e80e70 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue @@ -121,6 +121,11 @@ export default { show-group-by @filter-change="onFilterChange" /> - + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue index c44ab58e5..ccd71b3a4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue @@ -5,16 +5,38 @@ import { GROUP_BY_FILTER, METRIC_CHART } from './constants'; import fromUnixTime from 'date-fns/fromUnixTime'; import format from 'date-fns/format'; import { formatTime } from '@chatwoot/utils'; +import { useAlert } from 'dashboard/composables'; import ChartStats from './components/ChartElements/ChartStats.vue'; import BarChart from 'shared/components/charts/BarChart.vue'; +import ReportDrilldownDrawer from './components/ReportDrilldownDrawer.vue'; export default { - components: { ChartStats, BarChart }, + components: { ChartStats, BarChart, ReportDrilldownDrawer }, props: { groupBy: { type: Object, default: () => ({}), }, + from: { + type: Number, + default: 0, + }, + to: { + type: Number, + default: 0, + }, + reportType: { + type: String, + default: 'account', + }, + selectedItemId: { + type: [String, Number], + default: null, + }, + businessHours: { + type: Boolean, + default: false, + }, accountSummaryKey: { type: String, default: 'getAccountSummary', @@ -42,10 +64,27 @@ export default { ); return { calculateTrend, isAverageMetricType }; }, + data() { + return { + drilldownRequest: null, + drilldownMetric: null, + drilldownIndex: null, + }; + }, computed: { ...mapGetters({ accountReport: 'getAccountReports', + currentRole: 'getCurrentRole', }), + isAdmin() { + return this.currentRole === 'administrator'; + }, + canDrilldownPrev() { + return this.findDrillableIndex(this.drilldownIndex - 1, -1) !== null; + }, + canDrilldownNext() { + return this.findDrillableIndex(this.drilldownIndex + 1, 1) !== null; + }, metrics() { const reportKeys = Object.keys(this.reportKeys); const infoText = { @@ -139,6 +178,82 @@ export default { return options; }, + isDrilldownEnabled() { + return !!(this.from && this.to); + }, + onChartElementClick(metric, event) { + if (!this.isDrilldownEnabled()) return; + + const dataPoint = this.accountReport.data[metric.KEY]?.[event.dataIndex]; + if (!this.canOpenDrilldown(metric, dataPoint)) return; + if (!this.isAdmin) { + useAlert(this.$t('REPORT.DRILLDOWN.ADMIN_ONLY')); + return; + } + + this.openDrilldownAt(metric, event.dataIndex); + }, + openDrilldownAt(metric, dataIndex) { + const dataPoint = this.accountReport.data[metric.KEY]?.[dataIndex]; + if (!this.canOpenDrilldown(metric, dataPoint)) return; + + const labels = this.getCollection(metric).labels || []; + + this.drilldownMetric = metric; + this.drilldownIndex = dataIndex; + this.drilldownRequest = { + metric: metric.KEY, + metricName: metric.NAME, + bucketLabel: labels[dataIndex], + bucketTimestamp: dataPoint.timestamp, + bucketValue: dataPoint.value, + isAverageMetric: this.isAverageMetricType(metric.KEY), + from: this.from, + to: this.to, + type: this.reportType, + id: this.selectedItemId, + groupBy: this.groupBy?.period, + businessHours: this.businessHours, + }; + }, + navigateDrilldown(direction) { + const nextIndex = this.findDrillableIndex( + this.drilldownIndex + direction, + direction + ); + if (nextIndex === null) return; + + this.openDrilldownAt(this.drilldownMetric, nextIndex); + }, + findDrillableIndex(startIndex, step) { + if (!this.drilldownMetric) return null; + + const data = this.accountReport.data[this.drilldownMetric.KEY] || []; + for ( + let index = startIndex; + index >= 0 && index < data.length; + index += step + ) { + if (this.canOpenDrilldown(this.drilldownMetric, data[index])) + return index; + } + + return null; + }, + canOpenDrilldown(metric, dataPoint) { + if (!dataPoint) return false; + + if (this.isAverageMetricType(metric.KEY)) { + return dataPoint.count > 0; + } + + return dataPoint.value > 0; + }, + closeDrilldown() { + this.drilldownRequest = null; + this.drilldownMetric = null; + this.drilldownIndex = null; + }, }, }; @@ -168,6 +283,8 @@ export default { v-if="accountReport.data[metric.KEY].length" :collection="getCollection(metric)" :chart-options="getChartOptions(metric)" + :clickable="isDrilldownEnabled()" + @element-click="onChartElementClick(metric, $event)" /> {{ $t('REPORT.NO_ENOUGH_DATA') }} @@ -176,4 +293,23 @@ export default { + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue new file mode 100644 index 000000000..327db6291 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue @@ -0,0 +1,279 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownDrawer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownDrawer.vue new file mode 100644 index 000000000..0b245a35a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownDrawer.vue @@ -0,0 +1,312 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue index e54c9f53e..7b30ee128 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -69,6 +69,9 @@ export default { isAgentType() { return this.type === 'agent'; }, + selectedFilterId() { + return this.selectedFilter?.id || null; + }, reportKeys() { return { CONVERSATIONS: 'conversations_count', @@ -181,5 +184,10 @@ export default { v-if="filterItemsList.length" :group-by="groupBy" :report-keys="reportKeys" + :from="from" + :to="to" + :report-type="type" + :selected-item-id="selectedFilterId" + :business-hours="businessHours" /> diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownCard.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownCard.spec.js new file mode 100644 index 000000000..7fda49a36 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownCard.spec.js @@ -0,0 +1,195 @@ +import { mount } from '@vue/test-utils'; +import ReportDrilldownCard from '../ReportDrilldownCard.vue'; + +vi.mock('vue-router', () => ({ + useRoute: () => ({ + params: { + accountId: 1, + }, + }), +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params = {}) => { + if (key === 'REPORT.DRILLDOWN.MESSAGE_CREATED_AT') { + return `Message created at ${params.time}`; + } + if (key === 'REPORT.DRILLDOWN.EVENT_OCCURRED_AT') { + return `Event occurred at ${params.time}`; + } + if (key === 'REPORT.DRILLDOWN.INCOMING_MESSAGE') { + return 'Incoming message'; + } + if (key === 'REPORT.DRILLDOWN.OUTGOING_MESSAGE') { + return 'Outgoing message'; + } + return key; + }, + }), +})); + +vi.mock('shared/helpers/timeHelper', () => ({ + dynamicTime: timestamp => { + const timestamps = { + 1621103500: '2 minutes ago', + 1621103400: '4 days ago', + 1621103700: '4 days ago', + }; + return timestamps[timestamp] || 'less than a minute ago'; + }, + shortTimestamp: time => { + const timestamps = { + '2 minutes ago': '2m', + '4 days ago': '4d', + }; + return timestamps[time] || 'now'; + }, + dateFormat: timestamp => `date-${timestamp}`, +})); + +describe('ReportDrilldownCard.vue', () => { + const record = { + record_type: 'message', + conversation: { + id: 10, + display_id: 42, + contact_id: 11, + contact_name: 'Jane', + inbox_id: 12, + inbox_name: 'Website', + assignee_id: 13, + assignee_name: 'Alex', + status: 'open', + created_at: 1621103400, + last_activity_at: 1621103700, + last_message: { + id: 100, + content: 'Latest reply', + message_type: 'outgoing', + created_at: 1621103600, + }, + }, + message: { + id: 99, + content: 'Need help', + message_type: 'incoming', + created_at: 1621103500, + }, + metric_value: null, + occurred_at: 1621103500, + }; + + const mountCard = (props = {}) => + mount(ReportDrilldownCard, { + props: { + record, + ...props, + }, + global: { + mocks: { + $t: key => key, + }, + }, + }); + + beforeEach(() => { + vi.spyOn(window, 'open').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('opens the card conversation link in a new tab', async () => { + const wrapper = mountCard(); + + expect(wrapper.text()).toContain('#42'); + expect(wrapper.text()).toContain('Need help'); + expect(wrapper.find('.i-lucide-arrow-down-left').exists()).toBe(true); + expect(wrapper.find('[aria-label="Incoming message"]').exists()).toBe(true); + + await wrapper.find('[role="link"]').trigger('click'); + + expect(window.open).toHaveBeenCalledWith( + '/app/accounts/1/conversations/42?messageId=99', + '_blank', + 'noopener,noreferrer' + ); + }); + + it('renders only message created timestamp for message rows', () => { + const wrapper = mountCard(); + const messageCreatedLabel = wrapper + .findAll('[aria-label]') + .map(timestamp => timestamp.attributes('aria-label')) + .find(label => label.includes('Message created at')); + + expect(wrapper.text()).toContain('2m'); + expect(wrapper.text()).not.toContain('4d • 4d'); + expect(messageCreatedLabel).toContain('Message created at'); + }); + + it('renders separate contact, inbox, and agent links', async () => { + const wrapper = mountCard(); + const links = wrapper.findAll('a'); + + expect(links.map(link => link.attributes('href'))).toEqual([ + '/app/accounts/1/contacts/11', + '/app/accounts/1/inbox/12', + '/app/accounts/1/reports/agents/13', + ]); + expect(links.every(link => link.attributes('target') === '_blank')).toBe( + true + ); + expect( + links.every(link => link.classes().includes('text-n-slate-10')) + ).toBe(true); + expect( + links.every(link => !link.classes().includes('text-n-blue-11')) + ).toBe(true); + expect(wrapper.find('.i-lucide-contact').exists()).toBe(true); + expect(wrapper.find('.i-lucide-inbox').exists()).toBe(true); + expect(wrapper.find('.i-lucide-user-round').exists()).toBe(true); + + await links[0].trigger('click'); + + expect(window.open).not.toHaveBeenCalled(); + }); + + it('renders the last message for conversation rows', () => { + const wrapper = mountCard({ + record: { + ...record, + record_type: 'conversation', + message: null, + occurred_at: 1621103500, + }, + }); + + expect(wrapper.text()).toContain('Latest reply'); + expect(wrapper.text()).toContain('4d • 4d'); + }); + + it('renders event time alongside TimeAgo for event-backed conversation rows', () => { + const wrapper = mountCard({ + record: { + ...record, + record_type: 'conversation', + message: null, + event_name: 'conversation_bot_handoff', + occurred_at: 1621103500, + }, + }); + const eventOccurredLabel = wrapper + .findAll('[aria-label]') + .map(timestamp => timestamp.attributes('aria-label')) + .find(label => label.includes('Event occurred at')); + + expect(wrapper.text()).toContain('Latest reply'); + expect(wrapper.text()).toContain('4d • 4d'); + expect(wrapper.text()).toContain('2m'); + expect(eventOccurredLabel).toContain('Event occurred at'); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js new file mode 100644 index 000000000..d6cec362f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js @@ -0,0 +1,329 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { formatTime } from '@chatwoot/utils'; +import ReportsAPI from 'dashboard/api/reports'; +import ReportDrilldownDrawer from '../ReportDrilldownDrawer.vue'; + +vi.mock('dashboard/api/reports', () => ({ + default: { + getDrilldown: vi.fn(), + }, +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params = {}) => { + if (key === 'REPORT.DRILLDOWN.TITLE') { + return `${params.metric} details`; + } + if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_CONVERSATION') { + return `${params.count} conversations`; + } + if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_MESSAGE') { + return `${params.count} messages`; + } + return key; + }, + }), +})); + +describe('ReportDrilldownDrawer.vue', () => { + const request = { + metric: 'incoming_messages_count', + metricName: 'Messages received', + bucketLabel: '20-May', + bucketTimestamp: 1621103400, + from: 1621103400, + to: 1621621800, + type: 'account', + groupBy: 'day', + businessHours: false, + }; + + const payload = [ + { + record_type: 'message', + conversation: { + id: 10, + display_id: 42, + contact_id: 11, + contact_name: 'Jane', + inbox_id: 12, + inbox_name: 'Website', + assignee_id: 13, + assignee_name: 'Alex', + status: 'open', + created_at: 1621103400, + last_activity_at: 1621103700, + last_message: { + id: 100, + content: 'Latest reply', + message_type: 'outgoing', + created_at: 1621103600, + }, + }, + message: { + id: 99, + content: 'Need help', + message_type: 'incoming', + created_at: 1621103500, + }, + metric_value: null, + occurred_at: 1621103500, + }, + ]; + + const mountDrawer = options => + mount(ReportDrilldownDrawer, { + props: { open: true, ...request, ...options?.props }, + attachTo: options?.attachTo, + global: { + stubs: { + Teleport: true, + Transition: false, + Spinner: true, + Button: { + props: ['label'], + emits: ['click'], + template: + '', + }, + ReportDrilldownCard: { + props: ['record'], + template: + '
#{{ record.conversation.display_id }}
', + }, + }, + mocks: { + $t: key => key, + }, + }, + }); + + beforeEach(() => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 1, + current_page: 1, + record_type: 'message', + conversation_count: 1, + }, + payload, + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('loads and renders drilldown cards for the request', async () => { + const wrapper = mountDrawer(); + await flushPromises(); + + expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith( + expect.objectContaining({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + page: 1, + }) + ); + expect(wrapper.text()).toContain('Messages received'); + expect(wrapper.text()).toContain('1 conversations'); + expect(wrapper.find('[data-testid="drilldown-card"]').text()).toBe('#42'); + }); + + it('shows the bucket aggregate value for average metrics', async () => { + const wrapper = mountDrawer({ + props: { + metric: 'avg_first_response_time', + metricName: 'First response time', + isAverageMetric: true, + bucketValue: 2580, + }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain(formatTime(2580)); + }); + + it('shows both conversation and message counts when they differ (reply time)', async () => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 8, + current_page: 1, + record_type: 'message', + conversation_count: 5, + }, + payload, + }, + }); + const wrapper = mountDrawer({ + props: { + metric: 'reply_time', + isAverageMetric: true, + bucketValue: 2580, + }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('5 conversations'); + expect(wrapper.text()).toContain('8 messages'); + }); + + it('hides the message count when it matches the conversation count (first response time)', async () => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 5, + current_page: 1, + record_type: 'message', + conversation_count: 5, + }, + payload, + }, + }); + const wrapper = mountDrawer({ + props: { + metric: 'avg_first_response_time', + isAverageMetric: true, + bucketValue: 2580, + }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('5 conversations'); + expect(wrapper.text()).not.toContain('messages'); + }); + + it('shows the plain count as the bucket value for count metrics', async () => { + const wrapper = mountDrawer({ props: { bucketValue: 128 } }); + await flushPromises(); + + expect(wrapper.text()).toContain('128'); + expect(wrapper.text()).not.toContain(formatTime(128)); + }); + + it('hides the redundant subtitle count for conversation-count metrics', async () => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 5, + current_page: 1, + record_type: 'conversation', + conversation_count: 5, + }, + payload, + }, + }); + const wrapper = mountDrawer({ + props: { metric: 'conversations_count', bucketValue: 5 }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('5'); + expect(wrapper.text()).not.toContain('conversations'); + }); + + it('keeps the subtitle count when it differs from the stat value', async () => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 8, + current_page: 1, + record_type: 'conversation', + conversation_count: 5, + }, + payload, + }, + }); + const wrapper = mountDrawer({ + props: { metric: 'resolutions_count', bucketValue: 8 }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('5 conversations'); + }); + + it('emits close when the drawer close button is clicked', async () => { + const wrapper = mountDrawer(); + await flushPromises(); + + await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click'); + + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('emits navigate when the next button is clicked', async () => { + const wrapper = mountDrawer({ props: { canNext: true } }); + await flushPromises(); + + await wrapper + .get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]') + .trigger('click'); + + expect(wrapper.emitted('navigate')).toStrictEqual([[1]]); + }); + + it('does not emit navigate past the available range', async () => { + const wrapper = mountDrawer({ props: { canPrev: false } }); + await flushPromises(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + + expect(wrapper.emitted('navigate')).toBeUndefined(); + }); + + it('moves focus into the drawer when opened', async () => { + const target = document.createElement('div'); + document.body.appendChild(target); + const wrapper = mountDrawer({ attachTo: target }); + await flushPromises(); + await nextTick(); + + expect(document.activeElement).toBe( + wrapper.find('[role="dialog"]').element + ); + + wrapper.unmount(); + target.remove(); + }); + + it('closes on Escape even when focus is outside the drawer', async () => { + const target = document.createElement('div'); + document.body.appendChild(target); + const wrapper = mountDrawer({ attachTo: target }); + await flushPromises(); + + document.body.focus(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(wrapper.emitted('close')).toBeTruthy(); + + wrapper.unmount(); + target.remove(); + }); + + it('restores focus to the previously focused element when closed', async () => { + const opener = document.createElement('button'); + const target = document.createElement('div'); + document.body.appendChild(opener); + document.body.appendChild(target); + opener.focus(); + + const wrapper = mountDrawer({ attachTo: target }); + await flushPromises(); + await nextTick(); + + await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click'); + + expect(document.activeElement).toBe(opener); + + wrapper.unmount(); + target.remove(); + opener.remove(); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/composables/specs/useReportDrilldown.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/specs/useReportDrilldown.spec.js new file mode 100644 index 000000000..b83742b9b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/specs/useReportDrilldown.spec.js @@ -0,0 +1,124 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import ReportsAPI from 'dashboard/api/reports'; +import { useReportDrilldown } from '../useReportDrilldown'; + +vi.mock('dashboard/api/reports', () => ({ + default: { + getDrilldown: vi.fn(), + }, +})); + +const deferredPromise = () => { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +}; + +const drilldownRequest = overrides => ({ + metric: 'conversations_count', + bucketTimestamp: 1, + from: 1621103400, + to: 1621621800, + type: 'account', + groupBy: 'day', + businessHours: false, + ...overrides, +}); + +describe('useReportDrilldown', () => { + const mountComposable = () => + mount({ + setup() { + return useReportDrilldown(); + }, + template: '
', + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('does not request drilldown again for an identical active request', async () => { + const request = deferredPromise(); + ReportsAPI.getDrilldown.mockReturnValue(request.promise); + + const wrapper = mountComposable(); + wrapper.vm.open(drilldownRequest()); + wrapper.vm.open(drilldownRequest()); + + expect(ReportsAPI.getDrilldown).toHaveBeenCalledTimes(1); + }); + + it('aborts an in-flight request when a newer request is opened', async () => { + const firstRequest = deferredPromise(); + const secondRequest = deferredPromise(); + let firstSignal; + + ReportsAPI.getDrilldown + .mockImplementationOnce(({ signal }) => { + firstSignal = signal; + return firstRequest.promise; + }) + .mockReturnValueOnce(secondRequest.promise); + + const wrapper = mountComposable(); + wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 })); + wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 })); + + expect(firstSignal.aborted).toBe(true); + }); + + it('passes an abort signal to drilldown requests', async () => { + const request = deferredPromise(); + ReportsAPI.getDrilldown.mockReturnValue(request.promise); + + const wrapper = mountComposable(); + wrapper.vm.open(drilldownRequest()); + + expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + signal: expect.any(AbortSignal), + }) + ); + }); + + it('ignores stale responses when a newer request is opened first', async () => { + const firstRequest = deferredPromise(); + const secondRequest = deferredPromise(); + ReportsAPI.getDrilldown + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(secondRequest.promise); + + const wrapper = mountComposable(); + wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 })); + wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 })); + + secondRequest.resolve({ + data: { + meta: { current_page: 1, total_count: 1 }, + payload: [{ id: 'second' }], + }, + }); + await flushPromises(); + + expect(wrapper.vm.records).toEqual([{ id: 'second' }]); + expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 }); + + firstRequest.resolve({ + data: { + meta: { current_page: 1, total_count: 1 }, + payload: [{ id: 'first' }], + }, + }); + await flushPromises(); + + expect(wrapper.vm.records).toEqual([{ id: 'second' }]); + expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js new file mode 100644 index 000000000..7c37cd9cc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js @@ -0,0 +1,138 @@ +import { computed, ref } from 'vue'; +import ReportsAPI from 'dashboard/api/reports'; + +export function useReportDrilldown() { + const activeRequest = ref(null); + const records = ref([]); + const meta = ref({}); + const isFetching = ref(false); + const isFetchingMore = ref(false); + const hasError = ref(false); + let requestToken = 0; + let activeRequestController = null; + let activeRequestFingerprint = null; + + const hasRecords = computed(() => records.value.length > 0); + const hasMore = computed(() => { + return records.value.length < (meta.value.total_count || 0); + }); + + const isCurrentRequest = token => + token === requestToken && !!activeRequest.value; + + const requestFingerprint = request => + JSON.stringify({ + metric: request.metric, + bucketTimestamp: request.bucketTimestamp, + from: request.from, + to: request.to, + type: request.type, + id: request.id, + groupBy: request.groupBy, + businessHours: request.businessHours, + }); + + const abortActiveRequest = () => { + if (!activeRequestController) return; + + activeRequestController.abort(); + activeRequestController = null; + }; + + const isAbortError = error => + error?.name === 'AbortError' || + error?.name === 'CanceledError' || + error?.code === 'ERR_CANCELED'; + + const fetchPage = async (page, token = requestToken) => { + if (!activeRequest.value) return; + + const request = activeRequest.value; + const controller = new AbortController(); + const loadingState = page === 1 ? isFetching : isFetchingMore; + activeRequestController = controller; + loadingState.value = true; + hasError.value = false; + + try { + const response = await ReportsAPI.getDrilldown({ + ...request, + page, + signal: controller.signal, + }); + if (!isCurrentRequest(token)) return; + + meta.value = response.data.meta || {}; + records.value = + page === 1 + ? response.data.payload || [] + : [...records.value, ...(response.data.payload || [])]; + } catch (error) { + if (!isCurrentRequest(token) || isAbortError(error)) return; + + hasError.value = true; + } finally { + if (activeRequestController === controller) { + activeRequestController = null; + } + + if (isCurrentRequest(token)) { + loadingState.value = false; + } + } + }; + + const open = async request => { + const fingerprint = requestFingerprint(request); + if (activeRequestFingerprint === fingerprint) return; + + abortActiveRequest(); + requestToken += 1; + activeRequestFingerprint = fingerprint; + activeRequest.value = request; + records.value = []; + meta.value = {}; + hasError.value = false; + isFetchingMore.value = false; + await fetchPage(1, requestToken); + }; + + const close = () => { + abortActiveRequest(); + requestToken += 1; + activeRequestFingerprint = null; + activeRequest.value = null; + records.value = []; + meta.value = {}; + hasError.value = false; + isFetching.value = false; + isFetchingMore.value = false; + }; + + const loadMore = () => { + if ( + !activeRequest.value || + !hasMore.value || + isFetching.value || + isFetchingMore.value + ) { + return; + } + + fetchPage((meta.value.current_page || 1) + 1, requestToken); + }; + + return { + activeRequest, + records, + meta, + isFetching, + isFetchingMore, + hasError, + hasRecords, + hasMore, + open, + close, + loadMore, + }; +} diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/specs/ReportContainer.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/specs/ReportContainer.spec.js new file mode 100644 index 000000000..b45102611 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/specs/ReportContainer.spec.js @@ -0,0 +1,179 @@ +import { shallowMount } from '@vue/test-utils'; +import { useAlert } from 'dashboard/composables'; +import ReportContainer from '../ReportContainer.vue'; + +vi.mock('dashboard/composables', () => ({ + useAlert: vi.fn(), +})); + +vi.mock('dashboard/composables/useReportMetrics', () => ({ + useReportMetrics: () => ({ + calculateTrend: () => 0, + isAverageMetricType: key => + ['avg_first_response_time', 'avg_resolution_time', 'reply_time'].includes( + key + ), + }), +})); + +describe('ReportContainer.vue', () => { + const mountComponent = ({ + dataPoint = { value: 2, timestamp: 1621103400 }, + data, + reportKey = 'conversations_count', + role = 'administrator', + } = {}) => + shallowMount(ReportContainer, { + props: { + from: 1621103400, + to: 1621621800, + groupBy: { period: 'day' }, + reportType: 'inbox', + selectedItemId: 1, + businessHours: true, + reportKeys: { + CONVERSATIONS: reportKey, + }, + }, + global: { + mocks: { + $t: key => key, + $store: { + getters: { + getAccountReports: { + isFetching: { + [reportKey]: false, + }, + data: { + [reportKey]: data || [dataPoint], + }, + }, + getCurrentRole: role, + }, + }, + }, + stubs: { + ChartStats: true, + ReportDrilldownDrawer: { + name: 'ReportDrilldownDrawer', + props: [ + 'open', + 'metric', + 'metricName', + 'bucketLabel', + 'bucketTimestamp', + 'bucketValue', + 'isAverageMetric', + 'from', + 'to', + 'type', + 'id', + 'groupBy', + 'businessHours', + 'canPrev', + 'canNext', + ], + emits: ['navigate', 'close'], + template: '
', + }, + BarChart: { + name: 'BarChart', + props: ['collection', 'chartOptions', 'clickable'], + emits: ['elementClick'], + template: + '