Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup
This commit is contained in:
@@ -18,6 +18,8 @@ checks:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 32
|
||||
exclude_paths:
|
||||
- 'app/javascript/dashboard/composables/'
|
||||
file-lines:
|
||||
enabled: true
|
||||
config:
|
||||
@@ -25,6 +27,11 @@ checks:
|
||||
method-lines:
|
||||
config:
|
||||
threshold: 50
|
||||
exclude_paths:
|
||||
- 'app/javascript/dashboard/composables/'
|
||||
cognitive-complexity:
|
||||
exclude_paths:
|
||||
- 'app/javascript/dashboard/composables/'
|
||||
exclude_patterns:
|
||||
- 'spec/'
|
||||
- '**/specs/**/**'
|
||||
@@ -60,3 +67,4 @@ exclude_patterns:
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
|
||||
- '**/fixtures/**'
|
||||
- '**/*/fixtures.js'
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :account, :params
|
||||
|
||||
# rubocop:disable Lint/MissingSuper
|
||||
# the parent class has no initialize
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
||||
end
|
||||
# rubocop:enable Lint/MissingSuper
|
||||
|
||||
def build
|
||||
labels = account.labels.to_a
|
||||
return [] if labels.empty?
|
||||
|
||||
report_data = collect_report_data
|
||||
labels.map { |label| build_label_report(label, report_data) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collect_report_data
|
||||
conversation_filter = build_conversation_filter
|
||||
use_business_hours = use_business_hours?
|
||||
|
||||
{
|
||||
conversation_counts: fetch_conversation_counts(conversation_filter),
|
||||
resolved_counts: fetch_resolved_counts(conversation_filter),
|
||||
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
|
||||
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
|
||||
reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours)
|
||||
}
|
||||
end
|
||||
|
||||
def build_label_report(label, report_data)
|
||||
{
|
||||
id: label.id,
|
||||
name: label.title,
|
||||
conversations_count: report_data[:conversation_counts][label.title] || 0,
|
||||
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
|
||||
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
|
||||
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
|
||||
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
|
||||
}
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
end
|
||||
|
||||
def build_conversation_filter
|
||||
conversation_filter = { account_id: account.id }
|
||||
conversation_filter[:created_at] = range if range.present?
|
||||
|
||||
conversation_filter
|
||||
end
|
||||
|
||||
def fetch_conversation_counts(conversation_filter)
|
||||
fetch_counts(conversation_filter)
|
||||
end
|
||||
|
||||
def fetch_resolved_counts(conversation_filter)
|
||||
fetch_counts(conversation_filter.merge(status: :resolved))
|
||||
end
|
||||
|
||||
def fetch_counts(conversation_filter)
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(
|
||||
taggable_type: 'Conversation',
|
||||
context: 'labels',
|
||||
conversations: conversation_filter
|
||||
)
|
||||
.select('tags.name, COUNT(taggings.*) AS count')
|
||||
.group('tags.name')
|
||||
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
|
||||
end
|
||||
|
||||
def fetch_metrics(conversation_filter, event_name, use_business_hours)
|
||||
ReportingEvent
|
||||
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
|
||||
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(
|
||||
conversations: conversation_filter,
|
||||
name: event_name,
|
||||
taggings: { taggable_type: 'Conversation', context: 'labels' }
|
||||
)
|
||||
.group('tags.name')
|
||||
.order('tags.name')
|
||||
.select(
|
||||
'tags.name',
|
||||
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
|
||||
)
|
||||
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
@@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
end
|
||||
|
||||
def label
|
||||
render_report_with(V2::Reports::LabelSummaryBuilder)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
|
||||
@@ -15,7 +15,7 @@ class DashboardController < ActionController::Base
|
||||
private
|
||||
|
||||
def ensure_html_format
|
||||
head :not_acceptable unless request.format.html?
|
||||
render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json?
|
||||
end
|
||||
|
||||
def set_global_config
|
||||
|
||||
@@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper
|
||||
end
|
||||
|
||||
def generate_labels_report
|
||||
Current.account.labels.map do |label|
|
||||
label_report = report_builder({ type: :label, id: label.id }).short_summary
|
||||
[label.title] + generate_readable_report_metrics(label_report)
|
||||
reports = V2::Reports::LabelSummaryBuilder.new(
|
||||
account: Current.account,
|
||||
params: build_params({})
|
||||
).build
|
||||
|
||||
reports.map do |report|
|
||||
[report[:name]] + generate_readable_report_metrics(report)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getLabelReports({ since, until, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/label`, {
|
||||
params: {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new SummaryReportsAPI();
|
||||
|
||||
@@ -87,7 +87,7 @@ const newReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Label',
|
||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||
to: accountScopedRoute('label_reports'),
|
||||
to: accountScopedRoute('label_reports_index'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Inbox',
|
||||
|
||||
@@ -853,6 +853,8 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
:has-active-folders="hasActiveFolders"
|
||||
:active-status="activeStatus"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
:conversation-stats="conversationStats"
|
||||
:is-list-loading="chatListLoading"
|
||||
@add-folders="onClickOpenAddFoldersModal"
|
||||
@delete-folders="onClickOpenDeleteFoldersModal"
|
||||
@filters-modal="onToggleAdvanceFiltersModal"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { formatNumber } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
@@ -10,26 +11,13 @@ import SwitchLayout from 'dashboard/routes/dashboard/conversation/search/SwitchL
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
pageTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hasAppliedFilters: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
hasActiveFolders: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
activeStatus: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
pageTitle: { type: String, required: true },
|
||||
hasAppliedFilters: { type: Boolean, required: true },
|
||||
hasActiveFolders: { type: Boolean, required: true },
|
||||
activeStatus: { type: String, required: true },
|
||||
isOnExpandedLayout: { type: Boolean, required: true },
|
||||
conversationStats: { type: Object, required: true },
|
||||
isListLoading: { type: Boolean, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -62,6 +50,9 @@ const showV4View = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const allCount = computed(() => props.conversationStats?.allCount || 0);
|
||||
const formattedAllCount = computed(() => formatNumber(allCount.value));
|
||||
|
||||
const toggleConversationLayout = () => {
|
||||
const { LAYOUT_TYPES } = wootConstants;
|
||||
const {
|
||||
@@ -92,6 +83,15 @@ const toggleConversationLayout = () => {
|
||||
>
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="
|
||||
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
|
||||
"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
|
||||
:title="allCount"
|
||||
>
|
||||
{{ formattedAllCount }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
|
||||
|
||||
@@ -11,8 +11,6 @@ import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Linear from './linear/index.vue';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -36,12 +34,6 @@ const { isAWebWidgetInbox } = useInbox();
|
||||
|
||||
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||
const isFeatureEnabledonAccount = computed(
|
||||
() => store.getters['accounts/isFeatureEnabledonAccount']
|
||||
);
|
||||
const appIntegrations = computed(
|
||||
() => store.getters['integrations/getAppIntegrations']
|
||||
);
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta);
|
||||
|
||||
@@ -92,16 +84,6 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const isLinearIntegrationEnabled = computed(() =>
|
||||
appIntegrations.value.find(
|
||||
integration => integration.id === 'linear' && !!integration.hooks.length
|
||||
)
|
||||
);
|
||||
|
||||
const isLinearFeatureEnabled = computed(() =>
|
||||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,12 +144,6 @@ const isLinearFeatureEnabled = computed(() =>
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<Linear
|
||||
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
||||
:conversation-id="currentChat.id"
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
|
||||
import IssueHeader from './IssueHeader.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
issue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
linkId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const priorityMap = {
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const { createdAt } = props.issue;
|
||||
return format(new Date(createdAt), 'hh:mm a, MMM dd');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const assigneeDetails = props.issue.assignee;
|
||||
|
||||
if (!assigneeDetails) return null;
|
||||
const { name, avatarUrl } = assigneeDetails;
|
||||
|
||||
return {
|
||||
name,
|
||||
thumbnail: avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const labels = computed(() => {
|
||||
return props.issue.labels?.nodes || [];
|
||||
});
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
return priorityMap[props.issue.priority];
|
||||
});
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue', props.linkId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-n-alpha-3 backdrop-blur-[100px] z-50 px-4 py-3 border border-solid border-n-container w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<IssueHeader
|
||||
:identifier="issue.identifier"
|
||||
:link-id="linkId"
|
||||
:issue-url="issue.url"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<span class="mt-2 text-sm font-medium text-n-slate-12">
|
||||
{{ issue.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-row items-center h-6 gap-2">
|
||||
<UserAvatarWithName v-if="assignee" :user="assignee" class="py-1" />
|
||||
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
icon="status"
|
||||
size="14"
|
||||
:style="{ color: issue.state.color }"
|
||||
/>
|
||||
<h6 class="text-xs text-n-slate-12">
|
||||
{{ issue.state.name }}
|
||||
</h6>
|
||||
</div>
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
:icon="`priority-${priorityLabel.toLowerCase()}`"
|
||||
size="14"
|
||||
view-box="0 0 12 12"
|
||||
/>
|
||||
<h6 class="text-xs text-n-slate-12">{{ priorityLabel }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
|
||||
<woot-label
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:title="label.name"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
|
||||
createdAt: formattedDate,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -15,8 +14,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const isUnlinking = inject('isUnlinking');
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue');
|
||||
};
|
||||
@@ -27,36 +24,33 @@ const openIssue = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
|
||||
class="flex items-center gap-2 px-2 py-1.5 border rounded-lg border-n-strong"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="16"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span class="text-xs font-medium text-n-slate-12">
|
||||
{{ identifier }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="w-px h-3 text-n-weak bg-n-weak" />
|
||||
|
||||
<Button
|
||||
ghost
|
||||
link
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-unlink"
|
||||
class="!transition-none"
|
||||
:is-loading="isUnlinking"
|
||||
@click="unlinkIssue"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!transition-none"
|
||||
icon="i-lucide-arrow-up-right"
|
||||
class="!size-4"
|
||||
@click="openIssue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button ghost xs slate icon="i-lucide-unlink" @click="unlinkIssue" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
||||
import LinearIssueItem from './LinearIssueItem.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const linkedIssues = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const shouldShowCreateModal = ref(false);
|
||||
|
||||
const currentAccountId = getters.getCurrentAccountId;
|
||||
|
||||
const conversation = computed(
|
||||
() => getters.getConversationById.value(props.conversationId) || {}
|
||||
);
|
||||
|
||||
const hasIssues = computed(() => linkedIssues.value.length > 0);
|
||||
|
||||
const loadLinkedIssues = async () => {
|
||||
isLoading.value = true;
|
||||
linkedIssues.value = [];
|
||||
try {
|
||||
const response = await LinearAPI.getLinkedIssue(props.conversationId);
|
||||
linkedIssues.value = response.data || [];
|
||||
} catch (error) {
|
||||
// Silent fail - not critical for UX
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkIssue = async linkId => {
|
||||
try {
|
||||
await LinearAPI.unlinkIssue(linkId);
|
||||
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
|
||||
linkedIssues.value = linkedIssues.value.filter(
|
||||
issue => issue.id !== linkId
|
||||
);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
shouldShowCreateModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
shouldShowCreateModal.value = false;
|
||||
loadLinkedIssues();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadLinkedIssues();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssues();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-4 pt-3 pb-2">
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-plus"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasIssues" class="flex justify-center p-4">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[300px] overflow-y-auto">
|
||||
<LinearIssueItem
|
||||
v-for="linkedIssue in linkedIssues"
|
||||
:key="linkedIssue.id"
|
||||
class="pt-3 px-4 pb-4 border-b border-n-weak last:border-b-0"
|
||||
:linked-issue="linkedIssue"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="shouldShowCreateModal"
|
||||
:on-close="closeCreateModal"
|
||||
:close-on-backdrop-click="false"
|
||||
class="!items-start [&>div]:!top-12 [&>div]:sticky"
|
||||
>
|
||||
<CreateOrLinkIssue
|
||||
:conversation="conversation"
|
||||
:account-id="currentAccountId"
|
||||
@close="closeCreateModal"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import IssueHeader from './IssueHeader.vue';
|
||||
|
||||
const props = defineProps({
|
||||
linkedIssue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const priorityMap = {
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const issue = computed(() => props.linkedIssue.issue);
|
||||
|
||||
const assignee = computed(() => {
|
||||
const assigneeDetails = issue.value.assignee;
|
||||
if (!assigneeDetails) return null;
|
||||
return {
|
||||
name: assigneeDetails.name,
|
||||
thumbnail: assigneeDetails.avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const labels = computed(() => issue.value.labels?.nodes || []);
|
||||
|
||||
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue', props.linkedIssue.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col w-full">
|
||||
<IssueHeader
|
||||
:identifier="issue.identifier"
|
||||
:link-id="linkedIssue.id"
|
||||
:issue-url="issue.url"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<h3 class="mt-2 text-sm font-medium text-n-slate-12">
|
||||
{{ issue.title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="assignee" class="flex items-center gap-1.5">
|
||||
<Avatar :src="assignee.thumbnail" :name="assignee.name" :size="16" />
|
||||
<span class="text-xs capitalize truncate text-n-slate-12">
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-lucide-activity"
|
||||
class="size-4"
|
||||
:style="{ color: issue.state?.color }"
|
||||
/>
|
||||
<span class="text-xs text-n-slate-12">
|
||||
{{ issue.state?.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
|
||||
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1.5">
|
||||
<CardPriorityIcon :priority="priorityLabel.toLowerCase()" />
|
||||
<span class="text-xs text-n-slate-12">
|
||||
{{ priorityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="labels.length" class="flex flex-wrap">
|
||||
<woot-label
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:title="label.name"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
const getters = useStoreGetters();
|
||||
const accountId = getters.getCurrentAccountId;
|
||||
|
||||
const integrationId = 'linear';
|
||||
|
||||
const actionURL = computed(() =>
|
||||
frontendURL(
|
||||
`accounts/${accountId.value}/settings/integrations/${integrationId}`
|
||||
)
|
||||
);
|
||||
|
||||
const openLinearAccount = () => {
|
||||
window.open(actionURL.value, '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="w-12 h-12 mb-3">
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}.png`"
|
||||
class="object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:hidden dark:bg-n-alpha-2"
|
||||
/>
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
|
||||
class="hidden object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mb-4">
|
||||
<h3 class="mb-1.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.TITLE') }}
|
||||
</h3>
|
||||
<p v-if="isAdmin" class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.DESCRIPTION') }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.AGENT_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NextButton v-if="isAdmin" faded slate @click="openLinearAccount">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.BUTTON_TEXT') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,161 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch, defineOptions, provide } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
||||
import Issue from './Issue.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
parentWidth: {
|
||||
type: Number,
|
||||
default: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
name: 'Linear',
|
||||
});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const linkedIssue = ref(null);
|
||||
const shouldShow = ref(false);
|
||||
const shouldShowPopup = ref(false);
|
||||
const isUnlinking = ref(false);
|
||||
|
||||
provide('isUnlinking', isUnlinking);
|
||||
|
||||
const currentAccountId = getters.getCurrentAccountId;
|
||||
|
||||
const conversation = computed(() =>
|
||||
getters.getConversationById.value(props.conversationId)
|
||||
);
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
return linkedIssue.value === null
|
||||
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
|
||||
: null;
|
||||
});
|
||||
|
||||
const loadLinkedIssue = async () => {
|
||||
linkedIssue.value = null;
|
||||
try {
|
||||
const response = await LinearAPI.getLinkedIssue(props.conversationId);
|
||||
const issues = response.data;
|
||||
linkedIssue.value = issues && issues.length ? issues[0] : null;
|
||||
} catch (error) {
|
||||
// We don't want to show an error message here, as it's not critical. When someone clicks on the Linear icon, we can inform them that the integration is disabled.
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkIssue = async linkId => {
|
||||
try {
|
||||
isUnlinking.value = true;
|
||||
await LinearAPI.unlinkIssue(linkId);
|
||||
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
|
||||
linkedIssue.value = null;
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isUnlinking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowIssueIdentifier = computed(() => {
|
||||
if (!linkedIssue.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return props.parentWidth > 600;
|
||||
});
|
||||
|
||||
const openIssue = () => {
|
||||
if (!linkedIssue.value) shouldShowPopup.value = true;
|
||||
shouldShow.value = true;
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
shouldShowPopup.value = false;
|
||||
loadLinkedIssue();
|
||||
};
|
||||
|
||||
const closeIssue = () => {
|
||||
shouldShow.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadLinkedIssue();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssue();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative after:content-[''] after:h-5 after:bg-transparent after:top-5 after:w-full after:block after:absolute after:z-0"
|
||||
:class="{ group: linkedIssue }"
|
||||
>
|
||||
<Button
|
||||
v-on-clickaway="closeIssue"
|
||||
v-tooltip="tooltipText"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
class="!gap-1 group-hover:bg-n-alpha-2"
|
||||
@click="openIssue"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2] flex-shrink-0"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span
|
||||
v-if="shouldShowIssueIdentifier"
|
||||
class="text-xs font-medium text-n-slate-11"
|
||||
>
|
||||
{{ linkedIssue.issue.identifier }}
|
||||
</span>
|
||||
</Button>
|
||||
<Issue
|
||||
v-if="linkedIssue"
|
||||
:issue="linkedIssue.issue"
|
||||
:link-id="linkedIssue.id"
|
||||
class="absolute start-0 xl:start-auto xl:end-0 top-9 invisible group-hover:visible"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
<woot-modal
|
||||
v-model:show="shouldShowPopup"
|
||||
:on-close="closePopup"
|
||||
:close-on-backdrop-click="false"
|
||||
class="!items-start [&>div]:!top-12 [&>div]:sticky"
|
||||
>
|
||||
<CreateOrLinkIssue
|
||||
:conversation="conversation"
|
||||
:account-id="currentAccountId"
|
||||
@close="closePopup"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'contact_notes' },
|
||||
{ name: 'previous_conversation' },
|
||||
{ name: 'conversation_participants' },
|
||||
{ name: 'linear_issues' },
|
||||
{ name: 'shopify_orders' },
|
||||
]);
|
||||
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
"MACROS": "Macros",
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
|
||||
@@ -315,11 +315,18 @@
|
||||
"SUCCESS": "Issue unlinked successfully",
|
||||
"ERROR": "There was an error unlinking the issue, please try again"
|
||||
},
|
||||
"NO_LINKED_ISSUES": "No linked issues found",
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the integration?",
|
||||
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"CTA": {
|
||||
"TITLE": "Connect to Linear",
|
||||
"AGENT_DESCRIPTION": "Linear workspace is not connected. Request your administrator to connect a workspace to use this integration.",
|
||||
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
|
||||
"BUTTON_TEXT": "Connect Linear workspace"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
},
|
||||
"LABEL_REPORTS": {
|
||||
"HEADER": "Labels Overview",
|
||||
"DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
|
||||
@@ -559,6 +560,7 @@
|
||||
"INBOX": "Inbox",
|
||||
"AGENT": "Agent",
|
||||
"TEAM": "Team",
|
||||
"LABEL": "Label",
|
||||
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
|
||||
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
|
||||
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
|
||||
|
||||
@@ -19,6 +19,9 @@ import Draggable from 'vuedraggable';
|
||||
import MacrosList from './Macros/List.vue';
|
||||
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
|
||||
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||
import LinearIssuesList from 'dashboard/components/widgets/conversation/linear/IssuesList.vue';
|
||||
import LinearSetupCTA from 'dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -40,6 +43,13 @@ const {
|
||||
|
||||
const dragging = ref(false);
|
||||
const conversationSidebarItems = ref([]);
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const shopifyIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'shopify'
|
||||
@@ -49,6 +59,20 @@ const isShopifyFeatureEnabled = computed(
|
||||
() => shopifyIntegration.value.enabled
|
||||
);
|
||||
|
||||
const linearIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'linear'
|
||||
);
|
||||
|
||||
const isLinearIntegrationEnabled = computed(
|
||||
() => linearIntegration.value?.enabled || false
|
||||
);
|
||||
|
||||
const isLinearFeatureEnabled = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.LINEAR
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationId = computed(() => props.conversationId);
|
||||
@@ -103,6 +127,8 @@ onMounted(() => {
|
||||
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
|
||||
getContactDetails();
|
||||
store.dispatch('attributes/get', 0);
|
||||
// Load integrations to ensure linear integration state is available
|
||||
store.dispatch('integrations/get', 'linear');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -113,7 +139,7 @@ onMounted(() => {
|
||||
@close="closeContactPanel"
|
||||
/>
|
||||
<ContactInfo :contact="contact" :channel-type="channelType" />
|
||||
<div class="list-group pb-8">
|
||||
<div class="pb-8 list-group px-2">
|
||||
<Draggable
|
||||
:list="conversationSidebarItems"
|
||||
animation="200"
|
||||
@@ -125,140 +151,151 @@ onMounted(() => {
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div :key="element.name" class="px-2">
|
||||
<div
|
||||
v-if="element.name === 'conversation_actions'"
|
||||
class="conversation--actions"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="
|
||||
$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_ACTIONS')
|
||||
"
|
||||
:is-open="isContactSidebarItemOpen('is_conv_actions_open')"
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_conv_actions_open', value)
|
||||
"
|
||||
>
|
||||
<ConversationAction
|
||||
:conversation-id="conversationId"
|
||||
:inbox-id="inboxId"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="element.name === 'conversation_participants'"
|
||||
class="conversation--actions"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_PARTICIPANTS.SIDEBAR_TITLE')"
|
||||
:is-open="isContactSidebarItemOpen('is_conv_participants_open')"
|
||||
@toggle="
|
||||
value =>
|
||||
toggleSidebarUIState('is_conv_participants_open', value)
|
||||
"
|
||||
>
|
||||
<ConversationParticipant
|
||||
:conversation-id="conversationId"
|
||||
:inbox-id="inboxId"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'conversation_info'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_INFO')"
|
||||
:is-open="isContactSidebarItemOpen('is_conv_details_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_conv_details_open', value)
|
||||
"
|
||||
>
|
||||
<ConversationInfo
|
||||
:conversation-attributes="conversationAdditionalAttributes"
|
||||
:contact-attributes="contactAdditionalAttributes"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'contact_attributes'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
|
||||
:is-open="
|
||||
isContactSidebarItemOpen('is_contact_attributes_open')
|
||||
"
|
||||
compact
|
||||
@toggle="
|
||||
value =>
|
||||
toggleSidebarUIState('is_contact_attributes_open', value)
|
||||
"
|
||||
>
|
||||
<CustomAttributes
|
||||
attribute-type="contact_attribute"
|
||||
attribute-from="conversation_contact_panel"
|
||||
:contact-id="contact.id"
|
||||
:empty-state-message="
|
||||
$t('CONVERSATION_CUSTOM_ATTRIBUTES.NO_RECORDS_FOUND')
|
||||
"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'previous_conversation'">
|
||||
<AccordionItem
|
||||
v-if="contact.id"
|
||||
:title="
|
||||
$t('CONVERSATION_SIDEBAR.ACCORDION.PREVIOUS_CONVERSATION')
|
||||
"
|
||||
:is-open="isContactSidebarItemOpen('is_previous_conv_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_previous_conv_open', value)
|
||||
"
|
||||
>
|
||||
<ContactConversations
|
||||
:contact-id="contact.id"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<woot-feature-toggle
|
||||
v-else-if="element.name === 'macros'"
|
||||
feature-key="macros"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.MACROS')"
|
||||
:is-open="isContactSidebarItemOpen('is_macro_open')"
|
||||
compact
|
||||
@toggle="value => toggleSidebarUIState('is_macro_open', value)"
|
||||
>
|
||||
<MacrosList :conversation-id="conversationId" />
|
||||
</AccordionItem>
|
||||
</woot-feature-toggle>
|
||||
<div
|
||||
v-else-if="
|
||||
element.name === 'shopify_orders' && isShopifyFeatureEnabled
|
||||
<div
|
||||
v-if="element.name === 'conversation_actions'"
|
||||
class="conversation--actions"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_ACTIONS')"
|
||||
:is-open="isContactSidebarItemOpen('is_conv_actions_open')"
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_conv_actions_open', value)
|
||||
"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHOPIFY_ORDERS')"
|
||||
:is-open="isContactSidebarItemOpen('is_shopify_orders_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_shopify_orders_open', value)
|
||||
<ConversationAction
|
||||
:conversation-id="conversationId"
|
||||
:inbox-id="inboxId"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="element.name === 'conversation_participants'"
|
||||
class="conversation--actions"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_PARTICIPANTS.SIDEBAR_TITLE')"
|
||||
:is-open="isContactSidebarItemOpen('is_conv_participants_open')"
|
||||
@toggle="
|
||||
value =>
|
||||
toggleSidebarUIState('is_conv_participants_open', value)
|
||||
"
|
||||
>
|
||||
<ConversationParticipant
|
||||
:conversation-id="conversationId"
|
||||
:inbox-id="inboxId"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'conversation_info'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_INFO')"
|
||||
:is-open="isContactSidebarItemOpen('is_conv_details_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_conv_details_open', value)
|
||||
"
|
||||
>
|
||||
<ConversationInfo
|
||||
:conversation-attributes="conversationAdditionalAttributes"
|
||||
:contact-attributes="contactAdditionalAttributes"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'contact_attributes'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
|
||||
:is-open="isContactSidebarItemOpen('is_contact_attributes_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value =>
|
||||
toggleSidebarUIState('is_contact_attributes_open', value)
|
||||
"
|
||||
>
|
||||
<CustomAttributes
|
||||
attribute-type="contact_attribute"
|
||||
attribute-from="conversation_contact_panel"
|
||||
:contact-id="contact.id"
|
||||
:empty-state-message="
|
||||
$t('CONVERSATION_CUSTOM_ATTRIBUTES.NO_RECORDS_FOUND')
|
||||
"
|
||||
>
|
||||
<ShopifyOrdersList :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'contact_notes'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"
|
||||
:is-open="isContactSidebarItemOpen('is_contact_notes_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_contact_notes_open', value)
|
||||
"
|
||||
>
|
||||
<ContactNotes :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'previous_conversation'">
|
||||
<AccordionItem
|
||||
v-if="contact.id"
|
||||
:title="
|
||||
$t('CONVERSATION_SIDEBAR.ACCORDION.PREVIOUS_CONVERSATION')
|
||||
"
|
||||
:is-open="isContactSidebarItemOpen('is_previous_conv_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_previous_conv_open', value)
|
||||
"
|
||||
>
|
||||
<ContactConversations
|
||||
:contact-id="contact.id"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<woot-feature-toggle
|
||||
v-else-if="element.name === 'macros'"
|
||||
feature-key="macros"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.MACROS')"
|
||||
:is-open="isContactSidebarItemOpen('is_macro_open')"
|
||||
compact
|
||||
@toggle="value => toggleSidebarUIState('is_macro_open', value)"
|
||||
>
|
||||
<MacrosList :conversation-id="conversationId" />
|
||||
</AccordionItem>
|
||||
</woot-feature-toggle>
|
||||
<div
|
||||
v-else-if="
|
||||
element.name === 'linear_issues' && isLinearFeatureEnabled
|
||||
"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
|
||||
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_linear_issues_open', value)
|
||||
"
|
||||
>
|
||||
<LinearSetupCTA v-if="!isLinearIntegrationEnabled" />
|
||||
<LinearIssuesList v-else :conversation-id="conversationId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
element.name === 'shopify_orders' && isShopifyFeatureEnabled
|
||||
"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHOPIFY_ORDERS')"
|
||||
:is-open="isContactSidebarItemOpen('is_shopify_orders_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_shopify_orders_open', value)
|
||||
"
|
||||
>
|
||||
<ShopifyOrdersList :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'contact_notes'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"
|
||||
:is-open="isContactSidebarItemOpen('is_contact_notes_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_contact_notes_open', value)
|
||||
"
|
||||
>
|
||||
<ContactNotes :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import SummaryReports from './components/SummaryReports.vue';
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const summarReportsRef = ref(null);
|
||||
|
||||
const onDownloadClick = () => {
|
||||
summarReportsRef.value.downloadReports();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader
|
||||
:header-title="$t('LABEL_REPORTS.HEADER')"
|
||||
:header-description="$t('LABEL_REPORTS.DESCRIPTION')"
|
||||
>
|
||||
<V4Button
|
||||
:label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="onDownloadClick"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<SummaryReports
|
||||
ref="summarReportsRef"
|
||||
action-key="summaryReports/fetchLabelSummaryReports"
|
||||
getter-key="labels/getLabels"
|
||||
fetch-items-key="labels/get"
|
||||
summary-key="summaryReports/getLabelSummaryReports"
|
||||
type="label"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
|
||||
|
||||
import WootReports from './components/WootReports.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const label = useFunctionGetter('labels/getLabelById', route.params.id);
|
||||
|
||||
onMounted(() => store.dispatch('labels/get'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WootReports
|
||||
v-if="label.id"
|
||||
:key="label.id"
|
||||
type="label"
|
||||
getter-key="labels/getLabels"
|
||||
action-key="labels/get"
|
||||
:selected-item="label"
|
||||
:download-button-label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
|
||||
:report-title="$t('LABEL_REPORTS.HEADER')"
|
||||
has-back-button
|
||||
/>
|
||||
<div v-else class="w-full py-20">
|
||||
<Spinner class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
+3
-2
@@ -108,7 +108,8 @@ const tableData = computed(() =>
|
||||
} = rowMetrics;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
// we fallback on title, label for instance does not have a name property
|
||||
name: row.name ?? row.title,
|
||||
type: props.type,
|
||||
conversationsCount: renderCount(conversationsCount),
|
||||
avgFirstResponseTime: renderAvgTime(avgFirstResponseTime),
|
||||
@@ -177,7 +178,7 @@ defineExpose({ downloadReports });
|
||||
<template>
|
||||
<ReportFilterSelector @filter-change="onFilterChange" />
|
||||
<div
|
||||
class="flex-1 overflow-auto px-5 py-6 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<Table :table="table" />
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,12 @@ import Index from './Index.vue';
|
||||
import AgentReportsIndex from './AgentReportsIndex.vue';
|
||||
import InboxReportsIndex from './InboxReportsIndex.vue';
|
||||
import TeamReportsIndex from './TeamReportsIndex.vue';
|
||||
import LabelReportsIndex from './LabelReportsIndex.vue';
|
||||
|
||||
import AgentReportsShow from './AgentReportsShow.vue';
|
||||
import InboxReportsShow from './InboxReportsShow.vue';
|
||||
import TeamReportsShow from './TeamReportsShow.vue';
|
||||
import LabelReportsShow from './LabelReportsShow.vue';
|
||||
|
||||
import AgentReports from './AgentReports.vue';
|
||||
import InboxReports from './InboxReports.vue';
|
||||
@@ -104,6 +106,22 @@ const revisedReportRoutes = [
|
||||
},
|
||||
component: TeamReportsShow,
|
||||
},
|
||||
{
|
||||
path: 'labels_overview',
|
||||
name: 'label_reports_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: LabelReportsIndex,
|
||||
},
|
||||
{
|
||||
path: 'labels/:id',
|
||||
name: 'label_reports_show',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
component: LabelReportsShow,
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
|
||||
@@ -26,6 +26,9 @@ export const getters = {
|
||||
.filter(record => record.show_on_sidebar)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
},
|
||||
getLabelById: _state => id => {
|
||||
return _state.records.find(record => record.id === Number(id));
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('dashboard/api/summaryReports', () => ({
|
||||
getInboxReports: vi.fn(),
|
||||
getAgentReports: vi.fn(),
|
||||
getTeamReports: vi.fn(),
|
||||
getLabelReports: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -25,10 +26,12 @@ describe('Summary Reports Store', () => {
|
||||
inboxSummaryReports: [],
|
||||
agentSummaryReports: [],
|
||||
teamSummaryReports: [],
|
||||
labelSummaryReports: [],
|
||||
uiFlags: {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
isFetchingAgentSummaryReports: false,
|
||||
isFetchingTeamSummaryReports: false,
|
||||
isFetchingLabelSummaryReports: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -39,6 +42,7 @@ describe('Summary Reports Store', () => {
|
||||
inboxSummaryReports: [{ id: 1 }],
|
||||
agentSummaryReports: [{ id: 2 }],
|
||||
teamSummaryReports: [{ id: 3 }],
|
||||
labelSummaryReports: [{ id: 4 }],
|
||||
uiFlags: { isFetchingInboxSummaryReports: true },
|
||||
};
|
||||
|
||||
@@ -54,6 +58,10 @@ describe('Summary Reports Store', () => {
|
||||
expect(store.getters.getTeamSummaryReports(state)).toEqual([{ id: 3 }]);
|
||||
});
|
||||
|
||||
it('should return label summary reports', () => {
|
||||
expect(store.getters.getLabelSummaryReports(state)).toEqual([{ id: 4 }]);
|
||||
});
|
||||
|
||||
it('should return UI flags', () => {
|
||||
expect(store.getters.getUIFlags(state)).toEqual({
|
||||
isFetchingInboxSummaryReports: true,
|
||||
@@ -86,6 +94,14 @@ describe('Summary Reports Store', () => {
|
||||
expect(state.teamSummaryReports).toEqual(data);
|
||||
});
|
||||
|
||||
it('should set label summary report', () => {
|
||||
const state = { ...initialState };
|
||||
const data = [{ id: 4 }];
|
||||
|
||||
store.mutations.setLabelSummaryReport(state, data);
|
||||
expect(state.labelSummaryReports).toEqual(data);
|
||||
});
|
||||
|
||||
it('should merge UI flags with existing flags', () => {
|
||||
const state = {
|
||||
uiFlags: { flag1: true, flag2: false },
|
||||
@@ -185,5 +201,29 @@ describe('Summary Reports Store', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLabelSummaryReports', () => {
|
||||
it('should fetch label reports successfully', async () => {
|
||||
const params = { labelId: 789 };
|
||||
const mockResponse = {
|
||||
data: [{ label_id: 789, label_name: 'Test Label' }],
|
||||
};
|
||||
|
||||
SummaryReportsAPI.getLabelReports.mockResolvedValue(mockResponse);
|
||||
|
||||
await store.actions.fetchLabelSummaryReports({ commit }, params);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingLabelSummaryReports: true,
|
||||
});
|
||||
expect(SummaryReportsAPI.getLabelReports).toHaveBeenCalledWith(params);
|
||||
expect(commit).toHaveBeenCalledWith('setLabelSummaryReport', [
|
||||
{ labelId: 789, labelName: 'Test Label' },
|
||||
]);
|
||||
expect(commit).toHaveBeenCalledWith('setUIFlags', {
|
||||
isFetchingLabelSummaryReports: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,11 @@ const typeMap = {
|
||||
apiMethod: 'getTeamReports',
|
||||
mutationKey: 'setTeamSummaryReport',
|
||||
},
|
||||
label: {
|
||||
flagKey: 'isFetchingLabelSummaryReports',
|
||||
apiMethod: 'getLabelReports',
|
||||
mutationKey: 'setLabelSummaryReport',
|
||||
},
|
||||
};
|
||||
|
||||
async function fetchSummaryReports(type, params, { commit }) {
|
||||
@@ -38,10 +43,12 @@ export const initialState = {
|
||||
inboxSummaryReports: [],
|
||||
agentSummaryReports: [],
|
||||
teamSummaryReports: [],
|
||||
labelSummaryReports: [],
|
||||
uiFlags: {
|
||||
isFetchingInboxSummaryReports: false,
|
||||
isFetchingAgentSummaryReports: false,
|
||||
isFetchingTeamSummaryReports: false,
|
||||
isFetchingLabelSummaryReports: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,6 +62,9 @@ export const getters = {
|
||||
getTeamSummaryReports(state) {
|
||||
return state.teamSummaryReports;
|
||||
},
|
||||
getLabelSummaryReports(state) {
|
||||
return state.labelSummaryReports;
|
||||
},
|
||||
getUIFlags(state) {
|
||||
return state.uiFlags;
|
||||
},
|
||||
@@ -72,6 +82,10 @@ export const actions = {
|
||||
fetchTeamSummaryReports({ commit }, params) {
|
||||
return fetchSummaryReports('team', params, { commit });
|
||||
},
|
||||
|
||||
fetchLabelSummaryReports({ commit }, params) {
|
||||
return fetchSummaryReports('label', params, { commit });
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -84,6 +98,9 @@ export const mutations = {
|
||||
setTeamSummaryReport(state, data) {
|
||||
state.teamSummaryReports = data;
|
||||
},
|
||||
setLabelSummaryReport(state, data) {
|
||||
state.labelSummaryReports = data;
|
||||
},
|
||||
setUIFlags(state, uiFlag) {
|
||||
state.uiFlags = { ...state.uiFlags, ...uiFlag };
|
||||
},
|
||||
|
||||
@@ -94,13 +94,25 @@ class Telegram::IncomingMessageService
|
||||
end
|
||||
|
||||
def file_content_type
|
||||
return :image if params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
|
||||
return :audio if params[:message][:voice].present? || params[:message][:audio].present?
|
||||
return :video if params[:message][:video].present?
|
||||
return :image if image_message?
|
||||
return :audio if audio_message?
|
||||
return :video if video_message?
|
||||
|
||||
file_type(params[:message][:document][:mime_type])
|
||||
end
|
||||
|
||||
def image_message?
|
||||
params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
|
||||
end
|
||||
|
||||
def audio_message?
|
||||
params[:message][:voice].present? || params[:message][:audio].present?
|
||||
end
|
||||
|
||||
def video_message?
|
||||
params[:message][:video].present? || params[:message][:video_note].present?
|
||||
end
|
||||
|
||||
def attach_files
|
||||
return unless file
|
||||
|
||||
@@ -174,6 +186,9 @@ class Telegram::IncomingMessageService
|
||||
end
|
||||
|
||||
def visual_media_params
|
||||
params[:message][:photo].presence&.last || params.dig(:message, :sticker, :thumb).presence || params[:message][:video].presence
|
||||
params[:message][:photo].presence&.last ||
|
||||
params.dig(:message, :sticker, :thumb).presence ||
|
||||
params[:message][:video].presence ||
|
||||
params[:message][:video_note].presence
|
||||
end
|
||||
end
|
||||
|
||||
@@ -344,6 +344,7 @@ Rails.application.routes.draw do
|
||||
get :agent
|
||||
get :team
|
||||
get :inbox
|
||||
get :label
|
||||
end
|
||||
end
|
||||
resources :reports, only: [:index] do
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'faker'
|
||||
require 'active_support/testing/time_helpers'
|
||||
|
||||
class Seeders::Reports::ConversationCreator
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
def initialize(account:, resources:)
|
||||
@account = account
|
||||
@contacts = resources[:contacts]
|
||||
@inboxes = resources[:inboxes]
|
||||
@teams = resources[:teams]
|
||||
@labels = resources[:labels]
|
||||
@agents = resources[:agents]
|
||||
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
|
||||
end
|
||||
|
||||
def create_conversation(created_at:)
|
||||
conversation = nil
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
travel_to(created_at) do
|
||||
conversation = build_conversation
|
||||
conversation.save!
|
||||
|
||||
add_labels_to_conversation(conversation)
|
||||
create_messages_for_conversation(conversation)
|
||||
resolve_conversation_if_needed(conversation)
|
||||
end
|
||||
|
||||
travel_back
|
||||
end
|
||||
|
||||
conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_conversation
|
||||
contact = @contacts.sample
|
||||
inbox = @inboxes.sample
|
||||
|
||||
contact_inbox = find_or_create_contact_inbox(contact, inbox)
|
||||
assignee = select_assignee(inbox)
|
||||
team = select_team
|
||||
priority = @priorities.sample
|
||||
|
||||
contact_inbox.conversations.new(
|
||||
account: @account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
assignee: assignee,
|
||||
team: team,
|
||||
priority: priority
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_contact_inbox(contact, inbox)
|
||||
inbox.contact_inboxes.find_or_create_by!(
|
||||
contact: contact,
|
||||
source_id: SecureRandom.hex
|
||||
)
|
||||
end
|
||||
|
||||
def select_assignee(inbox)
|
||||
rand(10) < 8 ? inbox.members.sample : nil
|
||||
end
|
||||
|
||||
def select_team
|
||||
rand(10) < 7 ? @teams.sample : nil
|
||||
end
|
||||
|
||||
def add_labels_to_conversation(conversation)
|
||||
labels_to_add = @labels.sample(rand(5..20))
|
||||
conversation.update_labels(labels_to_add.map(&:title))
|
||||
end
|
||||
|
||||
def create_messages_for_conversation(conversation)
|
||||
message_creator = Seeders::Reports::MessageCreator.new(
|
||||
account: @account,
|
||||
agents: @agents,
|
||||
conversation: conversation
|
||||
)
|
||||
message_creator.create_messages
|
||||
end
|
||||
|
||||
def resolve_conversation_if_needed(conversation)
|
||||
return unless rand < 0.7
|
||||
|
||||
resolution_delay = rand((30.minutes)..(24.hours))
|
||||
travel(resolution_delay)
|
||||
conversation.update!(status: :resolved)
|
||||
|
||||
trigger_conversation_resolved_event(conversation)
|
||||
end
|
||||
|
||||
def trigger_conversation_resolved_event(conversation)
|
||||
event_data = { conversation: conversation }
|
||||
|
||||
ReportingEventListener.instance.conversation_resolved(
|
||||
Events::Base.new('conversation_resolved', Time.current, event_data)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'faker'
|
||||
require 'active_support/testing/time_helpers'
|
||||
|
||||
class Seeders::Reports::MessageCreator
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
MESSAGES_PER_CONVERSATION = 5
|
||||
|
||||
def initialize(account:, agents:, conversation:)
|
||||
@account = account
|
||||
@agents = agents
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def create_messages
|
||||
message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5)
|
||||
first_agent_reply = true
|
||||
|
||||
message_count.times do |i|
|
||||
message = create_single_message(i)
|
||||
first_agent_reply = handle_reply_tracking(message, i, first_agent_reply)
|
||||
end
|
||||
end
|
||||
|
||||
def create_single_message(index)
|
||||
is_incoming = index.even?
|
||||
add_realistic_delay(index, is_incoming) if index.positive?
|
||||
create_message(is_incoming)
|
||||
end
|
||||
|
||||
def handle_reply_tracking(message, index, first_agent_reply)
|
||||
return first_agent_reply if index.even? # Skip incoming messages
|
||||
|
||||
handle_agent_reply_events(message, first_agent_reply)
|
||||
false # No longer first reply after any agent message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_realistic_delay(_message_index, is_incoming)
|
||||
delay = calculate_message_delay(is_incoming)
|
||||
travel(delay)
|
||||
end
|
||||
|
||||
def calculate_message_delay(is_incoming)
|
||||
if is_incoming
|
||||
# Customer response time: 1 minute to 4 hours
|
||||
rand((1.minute)..(4.hours))
|
||||
elsif business_hours_active?(Time.current)
|
||||
# Agent response time varies by business hours
|
||||
rand((30.seconds)..(30.minutes))
|
||||
else
|
||||
rand((1.hour)..(8.hours))
|
||||
end
|
||||
end
|
||||
|
||||
def create_message(is_incoming)
|
||||
if is_incoming
|
||||
create_incoming_message
|
||||
else
|
||||
create_outgoing_message
|
||||
end
|
||||
end
|
||||
|
||||
def create_incoming_message
|
||||
@conversation.messages.create!(
|
||||
account: @account,
|
||||
inbox: @conversation.inbox,
|
||||
message_type: :incoming,
|
||||
content: generate_message_content,
|
||||
sender: @conversation.contact
|
||||
)
|
||||
end
|
||||
|
||||
def create_outgoing_message
|
||||
sender = @conversation.assignee || @agents.sample
|
||||
|
||||
@conversation.messages.create!(
|
||||
account: @account,
|
||||
inbox: @conversation.inbox,
|
||||
message_type: :outgoing,
|
||||
content: generate_message_content,
|
||||
sender: sender
|
||||
)
|
||||
end
|
||||
|
||||
def generate_message_content
|
||||
Faker::Lorem.paragraph(sentence_count: rand(1..5))
|
||||
end
|
||||
|
||||
def handle_agent_reply_events(message, is_first_reply)
|
||||
if is_first_reply
|
||||
trigger_first_reply_event(message)
|
||||
else
|
||||
trigger_reply_event(message)
|
||||
end
|
||||
end
|
||||
|
||||
def business_hours_active?(time)
|
||||
weekday = time.wday
|
||||
hour = time.hour
|
||||
weekday.between?(1, 5) && hour.between?(9, 17)
|
||||
end
|
||||
|
||||
def trigger_first_reply_event(message)
|
||||
event_data = {
|
||||
message: message,
|
||||
conversation: message.conversation
|
||||
}
|
||||
|
||||
ReportingEventListener.instance.first_reply_created(
|
||||
Events::Base.new('first_reply_created', Time.current, event_data)
|
||||
)
|
||||
end
|
||||
|
||||
def trigger_reply_event(message)
|
||||
waiting_since = calculate_waiting_since(message)
|
||||
|
||||
event_data = {
|
||||
message: message,
|
||||
conversation: message.conversation,
|
||||
waiting_since: waiting_since
|
||||
}
|
||||
|
||||
ReportingEventListener.instance.reply_created(
|
||||
Events::Base.new('reply_created', Time.current, event_data)
|
||||
)
|
||||
end
|
||||
|
||||
def calculate_waiting_since(message)
|
||||
last_customer_message = message.conversation.messages
|
||||
.where(message_type: :incoming)
|
||||
.where('created_at < ?', message.created_at)
|
||||
.order(:created_at)
|
||||
.last
|
||||
|
||||
last_customer_message&.created_at || message.conversation.created_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,234 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Reports Data Seeder
|
||||
#
|
||||
# Generates realistic test data for performance testing of reports and analytics.
|
||||
# Creates conversations, messages, contacts, agents, teams, and labels with proper
|
||||
# reporting events (first response times, resolution times, etc.) using time travel
|
||||
# to generate historical data with realistic timestamps.
|
||||
#
|
||||
# Usage:
|
||||
# ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
|
||||
#
|
||||
# This will create:
|
||||
# - 1000 conversations with realistic message exchanges
|
||||
# - 100 contacts with realistic profiles
|
||||
# - 20 agents assigned to teams and inboxes
|
||||
# - 5 teams with realistic distribution
|
||||
# - 30 labels with random assignments
|
||||
# - 3 inboxes with agent assignments
|
||||
# - Realistic reporting events with historical timestamps
|
||||
#
|
||||
# Note: This seeder clears existing data for the account before seeding.
|
||||
|
||||
require 'faker'
|
||||
require_relative 'conversation_creator'
|
||||
require_relative 'message_creator'
|
||||
|
||||
# rubocop:disable Rails/Output
|
||||
class Seeders::Reports::ReportDataSeeder
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
TOTAL_CONVERSATIONS = 1000
|
||||
TOTAL_CONTACTS = 100
|
||||
TOTAL_AGENTS = 20
|
||||
TOTAL_TEAMS = 5
|
||||
TOTAL_LABELS = 30
|
||||
TOTAL_INBOXES = 3
|
||||
MESSAGES_PER_CONVERSATION = 5
|
||||
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
|
||||
END_DATE = Time.current
|
||||
|
||||
def initialize(account:)
|
||||
raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
|
||||
|
||||
@account = account
|
||||
@teams = []
|
||||
@agents = []
|
||||
@labels = []
|
||||
@inboxes = []
|
||||
@contacts = []
|
||||
end
|
||||
|
||||
def perform!
|
||||
puts "Starting reports data seeding for account: #{@account.name}"
|
||||
|
||||
# Clear existing data
|
||||
clear_existing_data
|
||||
|
||||
create_teams
|
||||
create_agents
|
||||
create_labels
|
||||
create_inboxes
|
||||
create_contacts
|
||||
|
||||
create_conversations
|
||||
|
||||
puts "Completed reports data seeding for account: #{@account.name}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clear_existing_data
|
||||
puts "Clearing existing data for account: #{@account.id}"
|
||||
@account.teams.destroy_all
|
||||
@account.conversations.destroy_all
|
||||
@account.labels.destroy_all
|
||||
@account.inboxes.destroy_all
|
||||
@account.contacts.destroy_all
|
||||
@account.agents.destroy_all
|
||||
@account.reporting_events.destroy_all
|
||||
end
|
||||
|
||||
def create_teams
|
||||
TOTAL_TEAMS.times do |i|
|
||||
team = @account.teams.create!(
|
||||
name: "#{Faker::Company.industry} Team #{i + 1}"
|
||||
)
|
||||
@teams << team
|
||||
print "\rCreating teams: #{i + 1}/#{TOTAL_TEAMS}"
|
||||
end
|
||||
|
||||
print "\n"
|
||||
end
|
||||
|
||||
def create_agents
|
||||
TOTAL_AGENTS.times do |i|
|
||||
user = create_single_agent(i)
|
||||
assign_agent_to_teams(user)
|
||||
@agents << user
|
||||
print "\rCreating agents: #{i + 1}/#{TOTAL_AGENTS}"
|
||||
end
|
||||
|
||||
print "\n"
|
||||
end
|
||||
|
||||
def create_single_agent(index)
|
||||
random_suffix = SecureRandom.hex(4)
|
||||
user = User.create!(
|
||||
name: Faker::Name.name,
|
||||
email: "agent_#{index + 1}_#{random_suffix}@#{@account.domain || 'example.com'}",
|
||||
password: 'Password1!.',
|
||||
confirmed_at: Time.current
|
||||
)
|
||||
user.skip_confirmation!
|
||||
user.save!
|
||||
|
||||
AccountUser.create!(
|
||||
account_id: @account.id,
|
||||
user_id: user.id,
|
||||
role: :agent
|
||||
)
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def assign_agent_to_teams(user)
|
||||
teams_to_assign = @teams.sample(rand(1..3))
|
||||
teams_to_assign.each do |team|
|
||||
TeamMember.create!(
|
||||
team_id: team.id,
|
||||
user_id: user.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_labels
|
||||
TOTAL_LABELS.times do |i|
|
||||
label = @account.labels.create!(
|
||||
title: "Label-#{i + 1}-#{Faker::Lorem.word}",
|
||||
description: Faker::Company.catch_phrase,
|
||||
color: Faker::Color.hex_color
|
||||
)
|
||||
@labels << label
|
||||
print "\rCreating labels: #{i + 1}/#{TOTAL_LABELS}"
|
||||
end
|
||||
|
||||
print "\n"
|
||||
end
|
||||
|
||||
def create_inboxes
|
||||
TOTAL_INBOXES.times do |_i|
|
||||
inbox = create_single_inbox
|
||||
assign_agents_to_inbox(inbox)
|
||||
@inboxes << inbox
|
||||
print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
|
||||
end
|
||||
|
||||
print "\n"
|
||||
end
|
||||
|
||||
def create_single_inbox
|
||||
channel = Channel::WebWidget.create!(
|
||||
website_url: "https://#{Faker::Internet.domain_name}",
|
||||
account_id: @account.id
|
||||
)
|
||||
|
||||
@account.inboxes.create!(
|
||||
name: "#{Faker::Company.name} Website",
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def assign_agents_to_inbox(inbox)
|
||||
agents_to_assign = if @inboxes.empty?
|
||||
# First inbox gets all agents to ensure coverage
|
||||
@agents
|
||||
else
|
||||
# Subsequent inboxes get random selection with some overlap
|
||||
min_agents = [@agents.size / TOTAL_INBOXES, 10].max
|
||||
max_agents = [(@agents.size * 0.8).to_i, 50].min
|
||||
@agents.sample(rand(min_agents..max_agents))
|
||||
end
|
||||
|
||||
agents_to_assign.each do |agent|
|
||||
InboxMember.create!(inbox: inbox, user: agent)
|
||||
end
|
||||
end
|
||||
|
||||
def create_contacts
|
||||
TOTAL_CONTACTS.times do |i|
|
||||
contact = @account.contacts.create!(
|
||||
name: Faker::Name.name,
|
||||
email: Faker::Internet.email,
|
||||
phone_number: Faker::PhoneNumber.cell_phone_in_e164,
|
||||
identifier: SecureRandom.uuid,
|
||||
additional_attributes: {
|
||||
company: Faker::Company.name,
|
||||
city: Faker::Address.city,
|
||||
country: Faker::Address.country,
|
||||
customer_since: Faker::Date.between(from: 2.years.ago, to: Time.zone.today)
|
||||
}
|
||||
)
|
||||
@contacts << contact
|
||||
|
||||
print "\rCreating contacts: #{i + 1}/#{TOTAL_CONTACTS}"
|
||||
end
|
||||
|
||||
print "\n"
|
||||
end
|
||||
|
||||
def create_conversations
|
||||
conversation_creator = Seeders::Reports::ConversationCreator.new(
|
||||
account: @account,
|
||||
resources: {
|
||||
contacts: @contacts,
|
||||
inboxes: @inboxes,
|
||||
teams: @teams,
|
||||
labels: @labels,
|
||||
agents: @agents
|
||||
}
|
||||
)
|
||||
|
||||
TOTAL_CONVERSATIONS.times do |i|
|
||||
created_at = Faker::Time.between(from: START_DATE, to: END_DATE)
|
||||
conversation_creator.create_conversation(created_at: created_at)
|
||||
|
||||
completion_percentage = ((i + 1).to_f / TOTAL_CONVERSATIONS * 100).round
|
||||
print "\rCreating conversations: #{i + 1}/#{TOTAL_CONVERSATIONS} (#{completion_percentage}%)"
|
||||
end
|
||||
|
||||
print "\n"
|
||||
end
|
||||
end
|
||||
# rubocop:enable Rails/Output
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace :db do
|
||||
namespace :seed do
|
||||
desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events'
|
||||
task reports_data: :environment do
|
||||
if ENV['ACCOUNT_ID'].blank?
|
||||
puts 'Please provide an ACCOUNT_ID environment variable'
|
||||
puts 'Usage: ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data'
|
||||
exit 1
|
||||
end
|
||||
|
||||
ENV['ENABLE_ACCOUNT_SEEDING'] = 'true' if ENV['ENABLE_ACCOUNT_SEEDING'].blank?
|
||||
|
||||
account_id = ENV.fetch('ACCOUNT_ID', nil)
|
||||
account = Account.find(account_id)
|
||||
|
||||
puts "Starting reports data seeding for account: #{account.name} (ID: #{account.id})"
|
||||
|
||||
seeder = Seeders::Reports::ReportDataSeeder.new(account: account)
|
||||
seeder.perform!
|
||||
|
||||
puts "Finished seeding reports data for account: #{account.name}"
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||
"@chatwoot/utils": "^0.0.46",
|
||||
"@chatwoot/utils": "^0.0.47",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
|
||||
Generated
+5
-5
@@ -23,8 +23,8 @@ importers:
|
||||
specifier: 1.1.1-next
|
||||
version: 1.1.1-next
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.46
|
||||
version: 0.0.46
|
||||
specifier: ^0.0.47
|
||||
version: 0.0.47
|
||||
'@formkit/core':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
@@ -406,8 +406,8 @@ packages:
|
||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
||||
|
||||
'@chatwoot/utils@0.0.46':
|
||||
resolution: {integrity: sha512-a68CQ+aPFfyMr7dnXUUSt/kwHEazBd7Y8aidDZeDp5eL7sych7EpmT5XMTmhttlqMiRsmwETblXJJ2fBH6I44A==}
|
||||
'@chatwoot/utils@0.0.47':
|
||||
resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@codemirror/commands@6.7.0':
|
||||
@@ -5255,7 +5255,7 @@ snapshots:
|
||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
'@chatwoot/utils@0.0.46':
|
||||
'@chatwoot/utils@0.0.47':
|
||||
dependencies:
|
||||
date-fns: 2.30.0
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe V2::Reports::LabelSummaryBuilder do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let_it_be(:account) { create(:account) }
|
||||
let_it_be(:label_1) { create(:label, title: 'label_1', account: account) }
|
||||
let_it_be(:label_2) { create(:label, title: 'label_2', account: account) }
|
||||
let_it_be(:label_3) { create(:label, title: 'label_3', account: account) }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
business_hours: business_hours,
|
||||
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
|
||||
until: Time.zone.today.end_of_day.to_time.to_i.to_s,
|
||||
timezone_offset: 0
|
||||
}
|
||||
end
|
||||
let(:builder) { described_class.new(account: account, params: params) }
|
||||
|
||||
describe '#initialize' do
|
||||
let(:business_hours) { false }
|
||||
|
||||
it 'sets account and params' do
|
||||
expect(builder.account).to eq(account)
|
||||
expect(builder.params).to eq(params)
|
||||
end
|
||||
|
||||
it 'sets timezone from timezone_offset' do
|
||||
builder_with_offset = described_class.new(account: account, params: { timezone_offset: -8 })
|
||||
expect(builder_with_offset.instance_variable_get(:@timezone)).to eq('Pacific Time (US & Canada)')
|
||||
end
|
||||
|
||||
it 'defaults timezone when timezone_offset is not provided' do
|
||||
builder_without_offset = described_class.new(account: account, params: {})
|
||||
expect(builder_without_offset.instance_variable_get(:@timezone)).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build' do
|
||||
context 'when there are no labels' do
|
||||
let(:business_hours) { false }
|
||||
let(:empty_account) { create(:account) }
|
||||
let(:empty_builder) { described_class.new(account: empty_account, params: params) }
|
||||
|
||||
it 'returns empty array' do
|
||||
expect(empty_builder.build).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are labels but no conversations' do
|
||||
let(:business_hours) { false }
|
||||
|
||||
it 'returns zero values for all labels' do
|
||||
report = builder.build
|
||||
|
||||
expect(report.length).to eq(3)
|
||||
|
||||
bug_report = report.find { |r| r[:name] == 'label_1' }
|
||||
feature_request = report.find { |r| r[:name] == 'label_2' }
|
||||
customer_support = report.find { |r| r[:name] == 'label_3' }
|
||||
|
||||
[
|
||||
[bug_report, label_1, 'label_1'],
|
||||
[feature_request, label_2, 'label_2'],
|
||||
[customer_support, label_3, 'label_3']
|
||||
].each do |report_data, label, label_name|
|
||||
expect(report_data).to include(
|
||||
id: label.id,
|
||||
name: label_name,
|
||||
conversations_count: 0,
|
||||
avg_resolution_time: 0,
|
||||
avg_first_response_time: 0,
|
||||
avg_reply_time: 0,
|
||||
resolved_conversations_count: 0
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are labeled conversations with metrics' do
|
||||
before do
|
||||
travel_to(Time.zone.today) do
|
||||
user = create(:user, account: account)
|
||||
inbox = create(:inbox, account: account)
|
||||
create(:inbox_member, user: user, inbox: inbox)
|
||||
|
||||
gravatar_url = 'https://www.gravatar.com'
|
||||
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
# Create conversations with label_1
|
||||
3.times do
|
||||
conversation = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
created_at: Time.zone.today)
|
||||
create_list(:message, 2, message_type: 'outgoing',
|
||||
account: account, inbox: inbox,
|
||||
conversation: conversation,
|
||||
created_at: Time.zone.today + 1.hour)
|
||||
create_list(:message, 1, message_type: 'incoming',
|
||||
account: account, inbox: inbox,
|
||||
conversation: conversation,
|
||||
created_at: Time.zone.today + 2.hours)
|
||||
conversation.update_labels('label_1')
|
||||
conversation.label_list
|
||||
conversation.save!
|
||||
end
|
||||
|
||||
# Create conversations with label_2
|
||||
2.times do
|
||||
conversation = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
created_at: Time.zone.today)
|
||||
create_list(:message, 1, message_type: 'outgoing',
|
||||
account: account, inbox: inbox,
|
||||
conversation: conversation,
|
||||
created_at: Time.zone.today + 1.hour)
|
||||
conversation.update_labels('label_2')
|
||||
conversation.label_list
|
||||
conversation.save!
|
||||
end
|
||||
|
||||
# Resolve some conversations
|
||||
conversations_to_resolve = account.conversations.first(2)
|
||||
conversations_to_resolve.each(&:toggle_status)
|
||||
|
||||
# Create some reporting events
|
||||
account.conversations.reload.each_with_index do |conv, idx|
|
||||
# First response times
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conv,
|
||||
name: 'first_response',
|
||||
value: (30 + (idx * 10)) * 60,
|
||||
value_in_business_hours: (20 + (idx * 5)) * 60,
|
||||
created_at: Time.zone.today)
|
||||
|
||||
# Reply times
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conv,
|
||||
name: 'reply',
|
||||
value: (15 + (idx * 5)) * 60,
|
||||
value_in_business_hours: (10 + (idx * 3)) * 60,
|
||||
created_at: Time.zone.today)
|
||||
|
||||
# Resolution times for resolved conversations
|
||||
next unless conv.resolved?
|
||||
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conv,
|
||||
name: 'conversation_resolved',
|
||||
value: (60 + (idx * 30)) * 60,
|
||||
value_in_business_hours: (45 + (idx * 20)) * 60,
|
||||
created_at: Time.zone.today)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business hours is disabled' do
|
||||
let(:business_hours) { false }
|
||||
|
||||
it 'returns correct label stats using regular values' do
|
||||
report = builder.build
|
||||
|
||||
expect(report.length).to eq(3)
|
||||
|
||||
label_1_report = report.find { |r| r[:name] == 'label_1' }
|
||||
label_2_report = report.find { |r| r[:name] == 'label_2' }
|
||||
label_3_report = report.find { |r| r[:name] == 'label_3' }
|
||||
|
||||
expect(label_1_report).to include(
|
||||
conversations_count: 3,
|
||||
avg_first_response_time: be > 0,
|
||||
avg_reply_time: be > 0
|
||||
)
|
||||
|
||||
expect(label_2_report).to include(
|
||||
conversations_count: 2,
|
||||
avg_first_response_time: be > 0,
|
||||
avg_reply_time: be > 0
|
||||
)
|
||||
|
||||
expect(label_3_report).to include(
|
||||
conversations_count: 0,
|
||||
avg_first_response_time: 0,
|
||||
avg_reply_time: 0
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business hours is enabled' do
|
||||
let(:business_hours) { true }
|
||||
|
||||
it 'returns correct label stats using business hours values' do
|
||||
report = builder.build
|
||||
|
||||
expect(report.length).to eq(3)
|
||||
|
||||
label_1_report = report.find { |r| r[:name] == 'label_1' }
|
||||
label_2_report = report.find { |r| r[:name] == 'label_2' }
|
||||
|
||||
expect(label_1_report[:conversations_count]).to eq(3)
|
||||
expect(label_1_report[:avg_first_response_time]).to be > 0
|
||||
expect(label_1_report[:avg_reply_time]).to be > 0
|
||||
|
||||
expect(label_2_report[:conversations_count]).to eq(2)
|
||||
expect(label_2_report[:avg_first_response_time]).to be > 0
|
||||
expect(label_2_report[:avg_reply_time]).to be > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by date range' do
|
||||
let(:business_hours) { false }
|
||||
|
||||
before do
|
||||
travel_to(Time.zone.today) do
|
||||
user = create(:user, account: account)
|
||||
inbox = create(:inbox, account: account)
|
||||
create(:inbox_member, user: user, inbox: inbox)
|
||||
|
||||
gravatar_url = 'https://www.gravatar.com'
|
||||
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
# Conversation within range
|
||||
conversation_in_range = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
created_at: 2.days.ago)
|
||||
conversation_in_range.update_labels('label_1')
|
||||
conversation_in_range.label_list
|
||||
conversation_in_range.save!
|
||||
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation_in_range,
|
||||
name: 'first_response',
|
||||
value: 1800,
|
||||
created_at: 2.days.ago)
|
||||
|
||||
# Conversation outside range (too old)
|
||||
conversation_out_of_range = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
created_at: 1.week.ago)
|
||||
conversation_out_of_range.update_labels('label_1')
|
||||
conversation_out_of_range.label_list
|
||||
conversation_out_of_range.save!
|
||||
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation_out_of_range,
|
||||
name: 'first_response',
|
||||
value: 3600,
|
||||
created_at: 1.week.ago)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'only includes conversations within the date range' do
|
||||
report = builder.build
|
||||
|
||||
expect(report.length).to eq(3)
|
||||
|
||||
label_1_report = report.find { |r| r[:name] == 'label_1' }
|
||||
expect(label_1_report).not_to be_nil
|
||||
expect(label_1_report[:conversations_count]).to eq(1)
|
||||
expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with business hours parameter' do
|
||||
let(:business_hours) { 'true' }
|
||||
|
||||
before do
|
||||
travel_to(Time.zone.today) do
|
||||
user = create(:user, account: account)
|
||||
inbox = create(:inbox, account: account)
|
||||
create(:inbox_member, user: user, inbox: inbox)
|
||||
|
||||
gravatar_url = 'https://www.gravatar.com'
|
||||
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
conversation = create(:conversation, account: account,
|
||||
inbox: inbox, assignee: user,
|
||||
created_at: Time.zone.today)
|
||||
conversation.update_labels('label_1')
|
||||
conversation.label_list
|
||||
conversation.save!
|
||||
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
name: 'first_response',
|
||||
value: 3600,
|
||||
value_in_business_hours: 1800,
|
||||
created_at: Time.zone.today)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'properly casts string "true" to boolean and uses business hours values' do
|
||||
report = builder.build
|
||||
|
||||
expect(report.length).to eq(3)
|
||||
|
||||
label_1_report = report.find { |r| r[:name] == 'label_1' }
|
||||
expect(label_1_report).not_to be_nil
|
||||
expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -19,9 +19,10 @@ describe '/app/login', type: :request do
|
||||
end
|
||||
|
||||
context 'with non-HTML format' do
|
||||
it 'returns not acceptable for JSON' do
|
||||
get '/app/login', params: { format: 'json' }
|
||||
it 'returns not acceptable for JSON with error message' do
|
||||
get '/app/login', headers: { 'Accept' => 'application/json' }
|
||||
expect(response).to have_http_status(:not_acceptable)
|
||||
expect(response.parsed_body).to eq({ 'error' => 'Please use API routes instead of dashboard routes for JSON requests' })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -179,6 +179,35 @@ describe Telegram::IncomingMessageService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid video_note messages params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.mov')
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'video_note' => {
|
||||
'duration' => 3,
|
||||
'length' => 240,
|
||||
'thumb' => {
|
||||
'file_id' => 'AAMCBQADGQEAA4ZhXd78Xz6_c6gCzbdIkgGiXJcwwwACqwMAAp3x8Fbhf3EWamgCWAEAB20AAyEE',
|
||||
'file_unique_id' => 'AQADqwMAAp3x8FZy',
|
||||
'file_size' => 11_462,
|
||||
'width' => 240,
|
||||
'height' => 240
|
||||
},
|
||||
'file_id' => 'DQACAgUAAxkBAAIBY2FdJlhf8PC2E3IalXSvXWO5m8GBAALJAwACwqHgVhb0truM0uhwIQQ',
|
||||
'file_unique_id' => 'AgADyQMAAsKh4FY',
|
||||
'file_size' => 132_446
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid voice attachment params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.ogg')
|
||||
|
||||
Reference in New Issue
Block a user