diff --git a/.env.example b/.env.example index 69b1b9cde..c9f3c855c 100644 --- a/.env.example +++ b/.env.example @@ -272,9 +272,9 @@ AZURE_APP_SECRET= # ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false -# AI powered features -## OpenAI key -# OPENAI_API_KEY= +# AI powered features (Captain) +# The OpenAI API key and endpoint for Captain are not configured via .env. +# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT). # Housekeeping/Performance related configurations # Set to true if you want to remove stale contact inboxes 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/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 5e1609b64..4a8363fdd 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController end def reorder - Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) - head :ok + positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) + render json: { positions: positions } end private 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/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index e2ada7e86..7545b6d8f 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -22,4 +22,16 @@ module BillingHelper def agents(account) account.users.count end + + # current_period_end moved to the subscription item in newer Stripe API versions; read both. + def subscription_period_end(subscription) + subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end') + end + + def subscription_ends_on(subscription) + period_end = subscription_period_end(subscription) + return if period_end.blank? + + Time.zone.at(period_end) + end end diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 9e6d40a62..03456a288 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient { return axios.post(`${this.url}subscription`); } + selectBillingCurrency(currency) { + return axios.post(`${this.url}select_billing_currency`, { currency }); + } + getLimits() { return axios.get(`${this.url}limits`); } @@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient { createTopupCheckout(credits) { return axios.post(`${this.url}topup_checkout`, { credits }); } + + // Topup packages for the account's billing currency. + getTopupOptions() { + return axios.get(`${this.url}topup_options`); + } } export default new EnterpriseAccountAPI(); 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/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue index df2b22b7e..3486816d5 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue @@ -16,6 +16,10 @@ const props = defineProps({ type: Array, required: true, }, + contact: { + type: Object, + required: true, + }, }); const { t } = useI18n(); @@ -49,7 +53,9 @@ const unreadMessagesCount = computed(() => { const hasSlaThreshold = computed(() => { return ( - slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId + !props.contact?.blocked && + slaCardLabelRef.value?.hasSlaThreshold && + props.conversation?.appliedSla?.id ); }); diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue index f9a2507a1..be3ddf280 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue @@ -126,6 +126,7 @@ const onCardClick = e => { v-show="!showMessagePreviewWithoutMeta" ref="cardMessagePreviewWithMetaRef" :conversation="conversation" + :contact="contact" :account-labels="accountLabels" /> diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue index d0f8f0211..0119b7168 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue @@ -51,7 +51,9 @@ const unreadCount = computed(() => props.chat.unread_count); const slaCardLabel = useTemplateRef('slaCardLabel'); const hasSlaPolicyId = computed( - () => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold + () => + !props.currentContact?.blocked && + (props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold) ); const selectedModel = computed({ diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue index ff57d6c93..608bd84bd 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js b/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js new file mode 100644 index 000000000..a6800beb5 --- /dev/null +++ b/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js @@ -0,0 +1,222 @@ +import { mount } from '@vue/test-utils'; +import { h, nextTick } from 'vue'; +import DraggableReorderList from '../DraggableReorderList.vue'; + +// The component is pointer-driven, so we drive it through real pointer events on +// window while mocking the layout APIs jsdom does not implement: elementFromPoint +// (which card is under the cursor) and getBoundingClientRect (its geometry). +const elementAtPoint = { current: null }; + +const move = (clientX, clientY) => + window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY })); +const release = () => window.dispatchEvent(new MouseEvent('pointerup')); + +// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list. +const stubGeometry = wrapper => { + wrapper.element.getBoundingClientRect = () => ({ + left: 0, + right: 500, + top: 0, + bottom: 600, + }); + wrapper.findAll('[data-drag-id]').forEach((li, index) => { + const top = index * 50; + li.element.getBoundingClientRect = () => ({ + top, + height: 40, + bottom: top + 40, + }); + }); +}; + +const mountList = (props = {}) => + mount(DraggableReorderList, { + props: { items: [], ...props }, + slots: { + item: scope => h('div', { class: 'card' }, scope.item.title), + ghost: scope => h('div', { class: 'ghost' }, scope.item.title), + }, + global: { stubs: { Icon: true, teleport: true } }, + }); + +describe('DraggableReorderList', () => { + let wrapper; + + beforeEach(() => { + elementAtPoint.current = null; + document.elementFromPoint = vi.fn(() => elementAtPoint.current); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.useRealTimers(); + }); + + const startDragging = async id => { + stubGeometry(wrapper); + wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent( + new MouseEvent('pointerdown', { + button: 0, + clientX: 250, + clientY: 20, + bubbles: true, + }) + ); + await nextTick(); + }; + + it('renders each item through the item slot', () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + }); + + const cards = wrapper.findAll('.card'); + expect(cards).toHaveLength(2); + expect(cards[0].text()).toBe('Alpha'); + expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true); + expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true); + }); + + it('shows a grab affordance only when enabled', () => { + wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] }); + expect(wrapper.find('[data-drag-id="1"]').classes()).toContain( + 'cursor-grab' + ); + + wrapper.unmount(); + wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true }); + expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain( + 'cursor-grab' + ); + }); + + it('does not start a drag when disabled', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + disabled: true, + }); + await startDragging(1); + move(250, 200); + await nextTick(); + + expect(wrapper.emitted('dragging')).toBeUndefined(); + }); + + it('emits dragging true then false across a drag', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + }); + await startDragging(1); + elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element; + move(250, 60); + await nextTick(); + + expect(wrapper.emitted('dragging')[0]).toEqual([true]); + + release(); + await nextTick(); + expect(wrapper.emitted('dragging')[1]).toEqual([false]); + }); + + it('emits the midpoint position when dropped between two rows', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + { id: 3, title: 'Gamma', position: 30 }, + ], + }); + await startDragging(1); + + // Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap + // sits before Gamma; dropping there lands halfway between Beta and Gamma. + elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element; + move(250, 85); + await nextTick(); + release(); + await nextTick(); + + expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 }); + }); + + it('does not reorder when the only row on a page is dropped in place', async () => { + // P1: dragging the lone article on a later page and releasing without + // crossing to another page must be a no-op, not move it to the top. + wrapper = mountList({ + items: [{ id: 5, title: 'Solo', position: 260 }], + currentPage: 2, + totalPages: 2, + }); + await startDragging(5); + move(250, 300); + await nextTick(); + release(); + await nextTick(); + + expect(wrapper.emitted('dragging')).toEqual([[true], [false]]); + expect(wrapper.emitted('reorder')).toBeUndefined(); + }); + + it('turns the page after dwelling on a pageable edge', async () => { + vi.useFakeTimers(); + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + ], + currentPage: 1, + totalPages: 2, + }); + await startDragging(1); + + // Drag to the right edge over blank space (no card) and hold. + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + + expect(wrapper.emitted('navigatePage')[0]).toEqual([2]); + }); + + it('can still turn pages after releasing during a pending flip', async () => { + // Releasing while a flip fetch is in flight must clear paging state, or every + // later drag would be stuck unable to navigate. + vi.useFakeTimers(); + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + ], + currentPage: 1, + totalPages: 2, + }); + + // First drag: park at the edge to start a flip, then release before the new + // page arrives (items never change here). + await startDragging(1); + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + release(); + await nextTick(); + + // Second drag must be able to flip again. + await startDragging(1); + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + + expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]); + }); +}); diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue index 75aeb86d1..9e886d1d3 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue @@ -1,6 +1,5 @@ { a { @apply p-4; } + + .ProseMirror a { + @apply p-0; + } } } } diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 95ef53491..a5dec806d 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -57,7 +57,9 @@ const showMetaSection = computed(() => { ); }); -const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); +const hasSlaPolicyId = computed( + () => props.chat?.applied_sla?.id && !props.currentContact?.blocked +); const showLabelsSection = computed(() => { return props.chat.labels?.length > 0 || hasSlaPolicyId.value; diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index ccd1a8aae..b1e3bc603 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -93,7 +93,9 @@ const hasMultipleInboxes = computed( () => store.getters['inboxes/getInboxes'].length > 1 ); -const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); +const hasSlaPolicyId = computed( + () => props.chat?.applied_sla?.id && !currentContact.value?.blocked +); const copyConversationId = async () => { try { diff --git a/app/javascript/dashboard/components/widgets/conversation/components/SLACardLabel.vue b/app/javascript/dashboard/components/widgets/conversation/components/SLACardLabel.vue index bb6ee48d2..f5e2d4522 100644 --- a/app/javascript/dashboard/components/widgets/conversation/components/SLACardLabel.vue +++ b/app/javascript/dashboard/components/widgets/conversation/components/SLACardLabel.vue @@ -1,7 +1,7 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue index 33f299b9b..ce59a8738 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue @@ -4,20 +4,18 @@ import { useI18n } from 'vue-i18n'; import { useAlert } from 'dashboard/composables'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Button from 'dashboard/components-next/button/Button.vue'; +import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import CreditPackageCard from './CreditPackageCard.vue'; import EnterpriseAccountAPI from 'dashboard/api/enterprise/account'; +import { + formatCurrencyAmount, + DEFAULT_BILLING_CURRENCY, +} from 'dashboard/constants/billing'; -const emit = defineEmits(['close', 'success']); +const emit = defineEmits(['success']); const { t } = useI18n(); -const TOPUP_OPTIONS = [ - { credits: 1000, amount: 20.0, currency: 'usd' }, - { credits: 2500, amount: 50.0, currency: 'usd' }, - { credits: 6000, amount: 100.0, currency: 'usd' }, - { credits: 12000, amount: 200.0, currency: 'usd' }, -]; - const POPULAR_CREDITS_AMOUNT = 6000; const STEP_SELECT = 'select'; const STEP_CONFIRM = 'confirm'; @@ -27,16 +25,20 @@ const selectedCredits = ref(null); const isLoading = ref(false); const currentStep = ref(STEP_SELECT); +// Topup packages come from the backend for the account's billing currency. +const topupOptions = ref([]); +const optionsCurrency = ref(DEFAULT_BILLING_CURRENCY); +const isFetchingOptions = ref(false); +const fetchError = ref(false); + const selectedOption = computed(() => { - return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value); + return topupOptions.value.find(o => o.credits === selectedCredits.value); }); const formattedAmount = computed(() => { if (!selectedOption.value) return ''; - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: selectedOption.value.currency.toUpperCase(), - }).format(selectedOption.value.amount); + const { amount, currency } = selectedOption.value; + return formatCurrencyAmount(amount, currency || optionsCurrency.value); }); const formattedCredits = computed(() => { @@ -64,24 +66,44 @@ const handlePackageSelect = credits => { selectedCredits.value = credits; }; -const open = () => { - const popularOption = TOPUP_OPTIONS.find( +const selectDefaultOption = () => { + const popularOption = topupOptions.value.find( o => o.credits === POPULAR_CREDITS_AMOUNT ); - selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits; + selectedCredits.value = + popularOption?.credits || topupOptions.value[0]?.credits || null; +}; + +const fetchOptions = async () => { + isFetchingOptions.value = true; + fetchError.value = false; + try { + const { data } = await EnterpriseAccountAPI.getTopupOptions(); + topupOptions.value = data.options ?? []; + optionsCurrency.value = ( + data.currency || DEFAULT_BILLING_CURRENCY + ).toLowerCase(); + selectDefaultOption(); + } catch { + fetchError.value = true; + topupOptions.value = []; + } finally { + isFetchingOptions.value = false; + } +}; + +const open = () => { currentStep.value = STEP_SELECT; isLoading.value = false; + selectedCredits.value = null; dialogRef.value?.open(); + fetchOptions(); }; const close = () => { dialogRef.value?.close(); }; -const handleClose = () => { - emit('close'); -}; - const goToConfirmStep = () => { if (!selectedOption.value) return; currentStep.value = STEP_CONFIRM; @@ -127,32 +149,58 @@ defineExpose({ open, close }); :width="dialogWidth" :show-confirm-button="false" :show-cancel-button="false" - @close="handleClose" > - @@ -178,7 +226,7 @@ defineExpose({ open, close }); 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..b9df2e900 --- /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..f7c3bfd4b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownDrawer.vue @@ -0,0 +1,315 @@ + + + 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..10bc38bee --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js @@ -0,0 +1,352 @@ +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: { + TeleportWithDirection: { + template: '
', + }, + 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('anchors the drawer to the inline-end edge so it flips in RTL', async () => { + const wrapper = mountDrawer(); + await flushPromises(); + + const drawer = wrapper.get('[role="dialog"]'); + expect(drawer.classes()).toContain('end-0'); + expect(drawer.classes()).not.toContain('right-0'); + }); + + it('flips the navigation caret icons in RTL', async () => { + const wrapper = mountDrawer({ props: { canPrev: true, canNext: true } }); + await flushPromises(); + + expect( + wrapper.get('[aria-label="REPORT.DRILLDOWN.PREVIOUS_BUCKET"]').classes() + ).toContain('rtl:rotate-180'); + expect( + wrapper.get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]').classes() + ).toContain('rtl:rotate-180'); + }); + + 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: + '