Compare commits

..
Author SHA1 Message Date
Pranav e7847e3f69 Fix branding 2025-02-05 21:36:07 -08:00
Pranav 4a63b54630 CSVSafe -> CSV 2025-02-05 20:59:47 -08:00
Pranav 401babb844 Update Open Conversations 2025-02-05 20:59:08 -08:00
PranavandGitHub 3500b1a487 Merge branch 'heatmap-last-30-days' into live-agent-reports-controller 2025-02-05 20:08:13 -08:00
PranavandGitHub 2cf8856b62 Merge branch 'develop' into heatmap-last-30-days 2025-02-05 20:08:02 -08:00
Sivin VargheseandGitHub abcff9883f feat: Order previous conversations by last activity (#10825) 2025-02-05 19:50:25 -08:00
Pranav 698642fecb Add live team reports 2025-02-05 19:26:46 -08:00
Shivam MishraandGitHub d463a5cc30 fix: incorrect sender name on email meta (#10837)
Fixes https://github.com/chatwoot/chatwoot/issues/10807
2025-02-06 08:52:02 +05:30
Pranav b235d66f81 Add liveagentreports 2025-02-05 19:13:24 -08:00
Pranav 2545e26e8f Fix eslint 2025-02-05 19:06:20 -08:00
Pranav 6386142348 feat: Allow users to see heatmap for last 30 days 2025-02-05 18:33:23 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>PranavPranav
c750413094 chore(deps): Bump vitest from 2.0.1 to 3.0.5 (#10839)
Bumps
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
from 2.0.1 to 3.0.5

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-02-05 12:48:27 -08:00
f2a7e1da6b fix: Corepack pnpm issue (#10840)
So, a while back Circle CI builds and Heroku builds started to fail. 

From all the threads I read, it seems like the [npm registry rotated
it's signing
keys](https://github.com/pnpm/pnpm/issues/9014#issuecomment-2616589753)
New pnpm versions were signed with the new key. Corepack, however,
bundles a static set of trusted keys (from Node’s release), so it
continued verifying signatures only against the old key. When it
encountered packages signed with the new key, Corepack’s integrity check
failed with “Cannot find matching keyid” errors.This mismatch caused
Corepack’s integrity check to fail with “Cannot find matching keyid”
errors.

Workarounds include the following

1. Updating Corepack (to 0.31.0), they [upgraded their
package](https://github.com/nodejs/corepack/releases/tag/v0.31.0) to
include the new integrity check keys. But we seldom control what's going
on with the CI, also, updating this across our scripts is going to be a
painful task. Besides Heroku has [made some
fixes](https://github.com/heroku/buildpacks-nodejs/pull/1010) around
this
2. Disabling integrity checks 🔥 #YOLO
3. Pinning `pnpm` to older versions, or pinning it to a newer version
with the checksum in place.

Doing the third one here, running `corepack use pnpm@9.15.5` fixes this,
[ref](https://github.com/pnpm/pnpm/issues/9014#issuecomment-2623761494)

We can get rid of this over time as CDN caches used by build systems are
refreshed. But the change in this PR is not disruptive in anyway, only
rigidly secure.

Fixes: https://github.com/chatwoot/chatwoot/issues/10832

---

Here are the threads to follow 

- https://github.com/pnpm/pnpm/issues/9014
- https://github.com/pnpm/pnpm/issues/9029
- https://github.com/nodejs/corepack/issues/612
- https://github.com/nodejs/corepack/issues/616
- https://github.com/heroku/buildpacks-nodejs/pull/1010

---------

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
2025-02-05 10:12:29 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
41c7683e04 chore(deps-dev): bump vite from 5.4.8 to 5.4.12 (#10744)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite)
from 5.4.8 to 5.4.12.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v5.4.12</h2>
<p>This version contains a breaking change due to security fixes. See <a
href="https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6">https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6</a>
for more details.</p>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v5.4.11</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/ecd2375460edb4ae258fed4abe6c6f6ed7323b23/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v5.4.10</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.10/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v5.4.9</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.9/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted -->5.4.12 (2025-01-20)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix!: check host header to prevent DNS rebinding attacks and
introduce <code>server.allowedHosts</code> (<a
href="https://github.com/vitejs/vite/commit/9da4abc8dde7f032ca1f23f425c2060b9b9ebd34">9da4abc</a>)</li>
<li>fix!: default <code>server.cors: false</code> to disallow fetching
from untrusted origins (<a
href="https://github.com/vitejs/vite/commit/dfea38f1ff9f6fc0f0ca57927c527b0b9ffd2210">dfea38f</a>)</li>
<li>fix: verify token for HMR WebSocket connection (<a
href="https://github.com/vitejs/vite/commit/b71a5c89a1b4b913813ae665e6e04dd9d18c189c">b71a5c8</a>)</li>
<li>chore: add deps update changelog (<a
href="https://github.com/vitejs/vite/commit/ecd2375460edb4ae258fed4abe6c6f6ed7323b23">ecd2375</a>)</li>
</ul>
<h2><!-- raw HTML omitted -->5.4.11 (2024-11-11)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix(deps): update dependencies of postcss-modules (<a
href="https://github.com/vitejs/vite/commit/ceb15db613d107e29f7cc1d441364f7b5c831ed3">ceb15db</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18617">#18617</a></li>
</ul>
<h2><!-- raw HTML omitted -->5.4.10 (2024-10-23)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18367">#18367</a>,augment
hash for CSS files to prevent chromium erroring by loading previous fil
(<a
href="https://github.com/vitejs/vite/commit/7d1a3bcc436e1697b314bdc9d24c948664a1afb7">7d1a3bc</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18367">#18367</a>
<a
href="https://redirect.github.com/vitejs/vite/issues/18412">#18412</a></li>
</ul>
<h2><!-- raw HTML omitted -->5.4.9 (2024-10-14)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix: bump launch-editor-middleware to v2.9.1 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18348">#18348</a>)
(<a
href="https://github.com/vitejs/vite/commit/508d9ab83412c36e33f4c4ca57b891171429cdd3">508d9ab</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18348">#18348</a></li>
<li>fix(css): fix lightningcss dep url resolution with custom root (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18125">#18125</a>)
(<a
href="https://github.com/vitejs/vite/commit/eae00b561e04f1fe1679d3acf4f88b3c42019e4d">eae00b5</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18125">#18125</a></li>
<li>fix(data-uri): only match ids starting with <code>data:</code> (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18241">#18241</a>)
(<a
href="https://github.com/vitejs/vite/commit/96084d6e752c03332d101a50bce161a8e3f311cc">96084d6</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18241">#18241</a></li>
<li>fix(deps): bump tsconfck (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18322">#18322</a>)
(<a
href="https://github.com/vitejs/vite/commit/dc5434ce8781d206bcc4b55e90201691125e662c">dc5434c</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18322">#18322</a></li>
<li>fix(hmr): don't try to rewrite imports for direct CSS soft
invalidation (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18252">#18252</a>)
(<a
href="https://github.com/vitejs/vite/commit/851b258c346fdddd4467a12f41189b7855df8c43">851b258</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18252">#18252</a></li>
<li>fix(ssr): (backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18150">#18150</a>)
fix source map remapping with multiple sources (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18204">#18204</a>)
(<a
href="https://github.com/vitejs/vite/commit/262a8796d4be2c4b9c812f203ea9177f42360b13">262a879</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18204">#18204</a></li>
<li>chore: update all url references of vitejs.dev to vite.dev (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18276">#18276</a>)
(<a
href="https://github.com/vitejs/vite/commit/c23558a7af341d13f0c9da691047713965bc7e7d">c23558a</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18276">#18276</a></li>
<li>chore: update license copyright (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18278">#18278</a>)
(<a
href="https://github.com/vitejs/vite/commit/1864eb17b21ef21564bd66c6f6a30c2c495e2d4e">1864eb1</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18278">#18278</a></li>
<li>docs: update homepage (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18274">#18274</a>)
(<a
href="https://github.com/vitejs/vite/commit/ae4416349e1a373023d0e9e05955d96ae5fa9ab2">ae44163</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18274">#18274</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/f428aa9af8534b214abb09fe4456653eb09913e7"><code>f428aa9</code></a>
release: v5.4.12</li>
<li><a
href="https://github.com/vitejs/vite/commit/9da4abc8dde7f032ca1f23f425c2060b9b9ebd34"><code>9da4abc</code></a>
fix!: check host header to prevent DNS rebinding attacks and introduce
`serve...</li>
<li><a
href="https://github.com/vitejs/vite/commit/b71a5c89a1b4b913813ae665e6e04dd9d18c189c"><code>b71a5c8</code></a>
fix: verify token for HMR WebSocket connection</li>
<li><a
href="https://github.com/vitejs/vite/commit/dfea38f1ff9f6fc0f0ca57927c527b0b9ffd2210"><code>dfea38f</code></a>
fix!: default <code>server.cors: false</code> to disallow fetching from
untrusted origins</li>
<li><a
href="https://github.com/vitejs/vite/commit/ecd2375460edb4ae258fed4abe6c6f6ed7323b23"><code>ecd2375</code></a>
chore: add deps update changelog</li>
<li><a
href="https://github.com/vitejs/vite/commit/c54c860f9d90e4074e5321648f9c5ee9fbda7038"><code>c54c860</code></a>
release: v5.4.11</li>
<li><a
href="https://github.com/vitejs/vite/commit/5f52bc8b9e4090cdcaf3f704278db30dafc825cc"><code>5f52bc8</code></a>
release: v5.4.10</li>
<li><a
href="https://github.com/vitejs/vite/commit/7d1a3bcc436e1697b314bdc9d24c948664a1afb7"><code>7d1a3bc</code></a>
fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18367">#18367</a>,augment
hash for CSS files to prevent chromium erroring ...</li>
<li><a
href="https://github.com/vitejs/vite/commit/898d61f94b4316993963f593644821aae221d375"><code>898d61f</code></a>
release: v5.4.9</li>
<li><a
href="https://github.com/vitejs/vite/commit/508d9ab83412c36e33f4c4ca57b891171429cdd3"><code>508d9ab</code></a>
fix: bump launch-editor-middleware to v2.9.1 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18348">#18348</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v5.4.12/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.4.8&new-version=5.4.12)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-04 18:13:48 -08:00
PranavandGitHub 1b1ba3f8dd fix: Update the photo/video caption when an update event is received (#10804)
The update ensures proper handling of text updates in photo/video
messages by accounting for the caption attribute in addition to the text
attribute. This change enables consistent processing across both
messages.

Fixes https://github.com/chatwoot/chatwoot/issues/10760

Note: TIL, you can update the video/photo you’ve sent on Telegram, not
just the text. Currently, we’re not handling this. To support it, we
need to parse the payload and update the attachments accordingly. This
could be taken as a followup.
2025-02-03 12:44:10 -08:00
Sivin VargheseandGitHub bd94e5062d chore: Search improvements (#10801)
- Adds pagination support for search.
- Use composition API on all search related component.
- Minor UI improvements.
- Adds missing specs

Loom video
https://www.loom.com/share/5b01afa5c9204e7d97ff81b215621dde?sid=82ca6d22-ca8c-4d5e-8740-ba06ca4051ba
2025-02-03 19:34:50 +05:30
Sojan JoseandGitHub 3fb77fe806 chore: Resolve flaky spec for Contact country sorting (#10810)
We have been encountering errors in the community pipeline for the
contacts sort by country spec. Upon investigation, it was discovered
that the spec assumes the country code is used for sorting. However, the
sorting actually relies on the country attribute.

The payload from a previous spec run indicates that none of the contact
objects include the country attribute. This fix addresses the issue by
aligning the spec with the actual implementation logic.

Here’s an example payload from the previous spec run for reference:
<img width="1063" alt="Screenshot 2025-01-31 at 6 17 44 PM"
src="https://github.com/user-attachments/assets/df9b26e8-c5d0-432a-9d2a-16f76cb66967"
/>
2025-02-03 16:54:13 +05:30
PranavandGitHub c7d259d5fd chore: Update the behavior of Captain resolutions (#10794)
This PR ensures that only conversations from quick conversation channels
are resolved, avoiding resolutions on the email channel (we still need
to improve the UX here). It also updates the FAQ generation logic,
limiting it to conversations that had at least one human interaction.
2025-02-03 16:25:08 +05:30
Sivin VargheseandGitHub 5905b5301d fix: Prevent compose modal from closing when creating a link (#10809) 2025-02-03 15:08:20 +05:30
65 changed files with 2147 additions and 1495 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '20.12'
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
+12 -12
View File
@@ -5,30 +5,30 @@
version: '3'
services:
base:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.0"
RUBY_VERSION: "3.3.3"
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: "1000"
USER_GID: "1000"
USER_UID: '1000'
USER_GID: '1000'
image: base:latest
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.0"
RUBY_VERSION: "3.3.3"
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: "1000"
USER_GID: "1000"
USER_UID: '1000'
USER_GID: '1000'
volumes:
- ..:/workspace:cached
+1 -3
View File
@@ -23,12 +23,10 @@ jobs:
bundler-cache: true
- uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
+1 -2
View File
@@ -38,7 +38,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -48,7 +47,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
+3 -7
View File
@@ -19,13 +19,11 @@ jobs:
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 23
cache: 'pnpm'
- name: pnpm
@@ -39,7 +37,7 @@ jobs:
- name: setup env
run: |
cp .env.example .env
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
@@ -47,5 +45,3 @@ jobs:
- name: Size Check
run: pnpm run size
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
def index
@conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20)
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
end
private
@@ -0,0 +1,58 @@
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
before_action :set_group_scope, only: [:grouped_conversation_metrics]
def conversation_metrics
render json: {
open: @conversations.open.count,
unattended: @conversations.open.unattended.count,
unassigned: @conversations.open.unassigned.count,
pending: @conversations.pending.count
}
end
def grouped_conversation_metrics
count_by_group = @conversations.open.group(@group_scope).count
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
group_metrics = count_by_group.map do |group_id, count|
metric = {
open: count,
unattended: unattended_by_group[group_id] || 0,
unassigned: unassigned_by_group[group_id] || 0
}
metric[@group_scope] = group_id
metric
end
render json: group_metrics
end
private
def set_group_scope
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
team_id
assignee_id
].include?(permitted_params[:group_by])
@group_scope = permitted_params[:group_by]
end
def team
return unless permitted_params[:team_id]
@team ||= Current.account.teams.find(permitted_params[:team_id])
end
def load_conversations
scope = Current.account.conversations
scope = scope.where(team_id: team.id) if team.present?
@conversations = scope
end
def permitted_params
params.permit(:team_id, :group_by)
end
end
@@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper
end
def since_timestamp(date)
(date - 6.days).to_i.to_s
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
(date - number_of_days).to_i.to_s
end
def until_timestamp(date)
@@ -0,0 +1,20 @@
/* global axios */
import ApiClient from './ApiClient';
class LiveReportsAPI extends ApiClient {
constructor() {
super('live_reports', { accountScoped: true, apiVersion: 'v2' });
}
getConversationMetric(params = {}) {
return axios.get(`${this.url}/conversation_metrics`, { params });
}
getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
return axios.get(`${this.url}/grouped_conversation_metrics`, {
params: { group_by: groupBy },
});
}
}
export default new LiveReportsAPI();
+2 -2
View File
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
});
}
getConversationTrafficCSV() {
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset() },
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
});
}
+6 -3
View File
@@ -14,26 +14,29 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q }) {
contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
},
});
}
conversations({ q }) {
conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
},
});
}
messages({ q }) {
messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
},
});
}
@@ -171,6 +171,10 @@ watch(
{ immediate: true, deep: true }
);
const handleClickOutside = () => {
showComposeNewConversation.value = false;
};
onMounted(() => resetContacts());
const keyboardEvents = {
@@ -188,7 +192,12 @@ useKeyboardEvents(keyboardEvents);
<template>
<div
v-on-click-outside="() => (showComposeNewConversation = false)"
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
{ ignore: ['div.ProseMirror-prompt'] },
]"
class="relative"
:class="{
'z-40': showComposeNewConversation,
@@ -29,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
labelClass: {
type: String,
default: '',
},
});
const emit = defineEmits(['action']);
@@ -97,9 +101,13 @@ onMounted(() => {
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
}}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="filteredMenuItems.length === 0"
@@ -26,7 +26,18 @@ const ccEmail = computed(() => {
});
const senderName = computed(() => {
return sender.value.name ?? '';
const fromEmailAddress = fromEmail.value[0] ?? '';
const senderEmail = sender.value.email ?? '';
if (!fromEmailAddress && !senderEmail) return null;
// if the sender of the conversation and the sender of this particular
// email are the same, only then we return the sender name
if (fromEmailAddress === senderEmail) {
return sender.value.name;
}
return null;
});
const bccEmail = computed(() => {
@@ -59,11 +70,19 @@ const showMeta = computed(() => {
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
<template v-if="showMeta">
<div v-if="fromEmail[0]">
<span :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'">
{{ senderName }}
</span>
&lt;{{ fromEmail[0] }}&gt;
<div
v-if="fromEmail[0]"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
>
<template v-if="senderName">
<span>
{{ senderName }}
</span>
&lt;{{ fromEmail[0] }}&gt;
</template>
<template v-else>
{{ fromEmail[0] }}
</template>
</div>
<div v-if="toEmail.length">
{{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }}
@@ -42,6 +42,8 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const globalConfig = useMapGetter('globalConfig/get');
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
@@ -525,7 +527,12 @@ const menuItems = computed(() => {
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
<div class="grid flex-shrink-0 size-6 place-content-center">
<Logo />
<img
v-if="globalConfig.logoThumbnail"
:src="globalConfig.logoThumbnail"
class="h-5 w-5"
/>
<Logo v-else />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
@@ -0,0 +1,28 @@
import { ref, onBeforeUnmount } from 'vue';
export const useLiveRefresh = (callback, interval = 60000) => {
const timeoutId = ref(null);
const startRefetching = () => {
timeoutId.value = setTimeout(async () => {
await callback();
startRefetching();
}, interval);
};
const stopRefetching = () => {
if (timeoutId.value) {
clearTimeout(timeoutId.value);
timeoutId.value = null;
}
};
onBeforeUnmount(() => {
stopRefetching();
});
return {
startRefetching,
stopRefetching,
};
};
@@ -29,7 +29,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'team.changed': this.onTeamChanged,
'account.cache_invalidated': this.onCacheInvalidate,
};
}
@@ -115,11 +114,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onTeamChanged = data => {
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -476,6 +476,18 @@
"STATUS": "Status"
}
},
"TEAM_CONVERSATIONS": {
"ALL_TEAMS": "All Teams",
"HEADER": "Conversations by teams",
"LOADING_MESSAGE": "Loading team metrics...",
"NO_TEAMS": "There is no data available",
"TABLE_HEADER": {
"TEAM": "Team",
"OPEN": "Open",
"UNATTENDED": "Unattended",
"STATUS": "Status"
}
},
"AGENT_STATUS": {
"HEADER": "Agent status",
"ONLINE": "Online",
@@ -11,7 +11,10 @@
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"VIEW_MORE": "View more",
"LOAD_MORE": "Load more",
"SEARCHING_DATA": "Searching",
"LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus",
@@ -1,73 +1,59 @@
<script>
<script setup>
import { ref, useTemplateRef, onMounted, watch, nextTick } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ReadMore from './ReadMore.vue';
export default {
components: {
ReadMore,
const props = defineProps({
author: {
type: String,
default: '',
},
props: {
author: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
setup() {
const { formatMessage, highlightContent } = useMessageFormatter();
return {
formatMessage,
highlightContent,
};
searchTerm: {
type: String,
default: '',
},
data() {
return {
isOverflowing: false,
};
},
computed: {
messageContent() {
return this.formatMessage(this.content);
},
},
mounted() {
this.$watch(() => {
return this.$refs.messageContainer;
}, this.setOverflow);
});
this.$nextTick(this.setOverflow);
},
methods: {
setOverflow() {
const wrap = this.$refs.messageContainer;
if (wrap) {
const message = wrap.querySelector('.message-content');
this.isOverflowing = message.offsetHeight > 150;
}
},
escapeHtml(html) {
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
},
prepareContent(content = '') {
const escapedText = this.escapeHtml(content);
return this.highlightContent(
escapedText,
this.searchTerm,
'searchkey--highlight'
);
},
},
const { highlightContent } = useMessageFormatter();
const messageContainer = useTemplateRef('messageContainer');
const isOverflowing = ref(false);
const setOverflow = () => {
const wrap = messageContainer.value;
if (wrap) {
const message = wrap.querySelector('.message-content');
isOverflowing.value = message.offsetHeight > 150;
}
};
const escapeHtml = html => {
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
};
const prepareContent = (content = '') => {
const escapedText = escapeHtml(content);
return highlightContent(
escapedText,
props.searchTerm,
'searchkey--highlight'
);
};
onMounted(() => {
watch(() => {
return messageContainer.value;
}, setOverflow);
nextTick(setOverflow);
});
</script>
<template>
@@ -1,4 +1,6 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
shrink: {
type: Boolean,
@@ -18,17 +20,18 @@ defineEmits(['expand']);
>
<slot />
<div
class="bg-n-slate-3 rounded-md dark:bg-n-solid-3 absolute left-0 right-0 z-20 mx-auto mt-0 max-w-max bottom-2 backdrop-blur[100px]"
v-if="shrink"
class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t to-transparent from-n-background flex items-end justify-center pb-2"
>
<woot-button
v-if="shrink"
size="tiny"
variant="smooth"
color-scheme="primary"
<NextButton
:label="$t('SEARCH.READ_MORE')"
icon="i-lucide-chevrons-down"
blue
xs
faded
class="backdrop-filter backdrop-blur-[2px]"
@click.prevent="$emit('expand')"
>
{{ $t('SEARCH.READ_MORE') }}
</woot-button>
/>
</div>
</div>
</div>
@@ -1,52 +1,51 @@
<script>
export default {
emits: ['search'],
<script setup>
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { debounce } from '@chatwoot/utils';
data() {
return {
searchQuery: '',
isInputFocused: false,
};
},
mounted() {
this.$refs.searchInput.focus();
document.addEventListener('keydown', this.handler);
},
unmounted() {
document.removeEventListener('keydown', this.handler);
},
methods: {
handler(e) {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
this.$refs.searchInput.focus();
} else if (
e.key === 'Escape' &&
document.activeElement.tagName === 'INPUT'
) {
e.preventDefault();
this.$refs.searchInput.blur();
}
},
debounceSearch(e) {
this.searchQuery = e.target.value;
clearTimeout(this.debounce);
this.debounce = setTimeout(async () => {
if (this.searchQuery.length > 2 || this.searchQuery.match(/^[0-9]+$/)) {
this.$emit('search', this.searchQuery);
} else {
this.$emit('search', '');
}
}, 500);
},
onFocus() {
this.isInputFocused = true;
},
onBlur() {
this.isInputFocused = false;
},
},
const emit = defineEmits(['search']);
const searchQuery = ref('');
const isInputFocused = ref(false);
const searchInput = useTemplateRef('searchInput');
const handler = e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchInput.value.focus();
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
e.preventDefault();
searchInput.value.blur();
}
};
const debouncedEmit = debounce(
value =>
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
500
);
const onInput = e => {
searchQuery.value = e.target.value;
debouncedEmit(searchQuery.value);
};
const onFocus = () => {
isInputFocused.value = true;
};
const onBlur = () => {
isInputFocused.value = false;
};
onMounted(() => {
searchInput.value.focus();
document.addEventListener('keydown', handler);
});
onUnmounted(() => {
document.removeEventListener('keydown', handler);
});
</script>
<template>
@@ -76,7 +75,7 @@ export default {
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="debounceSearch"
@input="onInput"
/>
<woot-label
:title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"
@@ -1,35 +0,0 @@
<template>
<div class="search-input-box">
<woot-button
class="hollow"
size="small"
color-scheme="secondary"
is-expanded
>
<div class="search-input">
<fluent-icon icon="search" size="14px" class="search--icon" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap search-placeholder"
>
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
</span>
</div>
</woot-button>
</div>
</template>
<style lang="scss" scoped>
.search-input-box {
@apply p-2;
}
.search--icon {
@apply flex-shrink-0 text-slate-500 dark:text-slate-300;
}
.search-placeholder {
@apply text-slate-500 dark:text-slate-300;
}
.search-input {
@apply flex justify-center items-center gap-1;
}
</style>
@@ -1,6 +1,8 @@
<script setup>
import { frontendURL } from 'dashboard/helper/URLHelper';
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: {
@@ -37,28 +39,27 @@ const navigateTo = computed(() => {
<template>
<router-link
:to="navigateTo"
class="flex items-center p-2 rounded-md cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3"
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<woot-thumbnail :src="thumbnail" :username="name" size="24px" />
<div class="ml-2 rtl:mr-2 rtl:ml-0">
<h5 class="text-sm name text-n-slate-12 dark:text-n-slate-12">
<Avatar
:name="name"
:src="thumbnail"
:size="24"
rounded-full
class="mt-0.5"
/>
<div class="ml-2 rtl:mr-2 min-w-0 rtl:ml-0">
<h5 class="text-sm name truncate min-w-0 text-n-slate-12">
{{ name }}
</h5>
<p
class="flex items-center gap-1 m-0 text-sm text-slate-600 dark:text-slate-200"
class="grid items-center m-0 gap-1 text-sm grid-cols-[minmax(0,1fr)_auto_auto]"
>
<span v-if="email" class="email text-n-slate-12 dark:text-n-slate-12">{{
email
}}</span>
<span
v-if="phone"
class="separator text-n-slate-10 dark:text-n-slate-10"
>
</span>
<span v-if="phone" class="phone text-n-slate-12 dark:text-n-slate-12">
{{ phone }}
<span v-if="email" class="truncate text-n-slate-12" :title="email">
{{ email }}
</span>
<span v-if="phone" class="text-n-slate-10"></span>
<span v-if="phone" class="text-n-slate-12">{{ phone }}</span>
</p>
</div>
</router-link>
@@ -1,38 +1,29 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultContactItem from './SearchResultContactItem.vue';
export default {
components: {
SearchResultSection,
SearchResultContactItem,
defineProps({
contacts: {
type: Array,
default: () => [],
},
props: {
contacts: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
};
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script>
<template>
@@ -43,7 +34,7 @@ export default {
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="contacts.length" class="space-y-1.5">
<ul v-if="contacts.length" class="space-y-1.5 list-none">
<li v-for="contact in contacts" :key="contact.id">
<SearchResultContactItem
:id="contact.id"
@@ -1,72 +1,69 @@
<script>
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
export default {
components: {
InboxName,
const props = defineProps({
id: {
type: Number,
default: 0,
},
props: {
id: {
type: Number,
default: 0,
},
inbox: {
type: Object,
default: () => ({}),
},
name: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
inbox: {
type: Object,
default: () => ({}),
},
computed: {
navigateTo() {
const params = {};
if (this.messageId) {
params.messageId = this.messageId;
}
return frontendURL(
`accounts/${this.accountId}/conversations/${this.id}`,
params
);
},
createdAtTime() {
return dynamicTime(this.createdAt);
},
name: {
type: String,
default: '',
},
};
email: {
type: String,
default: '',
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
});
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
params.messageId = props.messageId;
}
return frontendURL(
`accounts/${props.accountId}/conversations/${props.id}`,
params
);
});
const createdAtTime = dynamicTime(props.createdAt);
</script>
<template>
<router-link
:to="navigateTo"
class="flex p-2 rounded-md cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3"
class="flex p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<div
class="flex items-center justify-center flex-shrink-0 w-6 h-6 rounded bg-n-brand/10 dark:bg-n-brand/40 text-n-blue-text dark:text-n-blue-text"
>
<fluent-icon icon="chat-multiple" :size="14" />
</div>
<Avatar
name="chats"
:size="24"
icon-name="i-lucide-messages-square"
class="[&>span]:rounded"
/>
<div class="flex-grow min-w-0 ml-2">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center min-w-0 justify-between gap-1 mb-1">
<div class="flex">
<woot-label
class="!bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong m-0"
@@ -83,29 +80,25 @@ export default {
/>
</div>
</div>
<div>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<span
class="text-xs font-normal min-w-0 truncate text-n-slate-11 dark:text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<div class="flex gap-2">
<h5
v-if="name"
class="m-0 text-sm text-n-slate-12 dark:text-n-slate-12"
class="m-0 text-sm min-w-0 truncate text-n-slate-12 dark:text-n-slate-12"
>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
<span class="text-xs font-norma text-n-slate-11 dark:text-n-slate-11">
{{ $t('SEARCH.FROM') }}:
</span>
{{ name }}
</h5>
<h5
v-if="email"
class="m-0 overflow-hidden text-sm text-n-slate-12 dark:text-n-slate-12 whitespace-nowrap text-ellipsis"
class="m-0 overflow-hidden text-sm text-n-slate-12 dark:text-n-slate-12 truncate"
>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
@@ -1,37 +1,28 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
export default {
components: {
SearchResultSection,
SearchResultConversationItem,
defineProps({
conversations: {
type: Array,
default: () => [],
},
props: {
conversations: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
};
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script>
<template>
@@ -39,10 +30,10 @@ export default {
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
:empty="!conversations.length"
:query="query"
:show-title="showTitle || isFetching"
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="conversations.length" class="space-y-1.5">
<ul v-if="conversations.length" class="space-y-1.5 list-none">
<li v-for="conversation in conversations" :key="conversation.id">
<SearchResultConversationItem
:id="conversation.id"
@@ -1,45 +1,37 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
import SearchResultSection from './SearchResultSection.vue';
import MessageContent from './MessageContent.vue';
export default {
components: {
SearchResultConversationItem,
SearchResultSection,
MessageContent,
defineProps({
messages: {
type: Array,
default: () => [],
},
props: {
messages: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
methods: {
getName(message) {
return message && message.sender && message.sender.name
? message.sender.name
: this.$t('SEARCH.BOT_LABEL');
},
showTitle: {
type: Boolean,
default: true,
},
});
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const getName = message => {
return message && message.sender && message.sender.name
? message.sender.name
: t('SEARCH.BOT_LABEL');
};
</script>
@@ -51,7 +43,7 @@ export default {
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="messages.length" class="space-y-1.5">
<ul v-if="messages.length" class="space-y-1.5 list-none">
<li v-for="message in messages" :key="message.id">
<SearchResultConversationItem
:id="message.conversation_id"
@@ -32,14 +32,14 @@ const titleCase = computed(() => props.title.toLowerCase());
<div v-if="showTitle" class="sticky top-0 p-2 z-50 mb-0.5 bg-n-background">
<h3 class="text-sm text-n-slate-12">{{ title }}</h3>
</div>
<slot />
<woot-loading-state
v-if="isFetching"
:message="$t('SEARCH.SEARCHING_DATA')"
:message="empty ? $t('SEARCH.SEARCHING_DATA') : $t('SEARCH.LOADING_DATA')"
/>
<slot v-else />
<div
v-if="empty && !isFetching"
class="flex items-center justify-center px-4 py-6 m-2 rounded-md bg-n-slate-2 dark:bg-n-solid-3"
class="flex items-center justify-center px-4 py-6 m-2 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="mx-2 my-0 text-center text-n-slate-11">
@@ -1,39 +1,38 @@
<script>
export default {
props: {
tabs: {
type: Array,
default: () => [],
},
selectedTab: {
type: Number,
default: 0,
},
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
tabs: {
type: Array,
default: () => [],
},
emits: ['tabChange'],
data() {
return {
activeTab: 0,
};
},
watch: {
selectedTab(value, oldValue) {
if (value !== oldValue) {
this.activeTab = this.selectedTab;
}
},
},
methods: {
onTabChange(index) {
this.activeTab = index;
this.$emit('tabChange', this.tabs[index].key);
},
selectedTab: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['tabChange']);
const activeTab = ref(props.selectedTab);
watch(
() => props.selectedTab,
(value, oldValue) => {
if (value !== oldValue) {
activeTab.value = props.selectedTab;
}
}
);
const onTabChange = index => {
activeTab.value = index;
emit('tabChange', props.tabs[index].key);
};
</script>
<template>
<div class="tab-container">
<div class="mt-1 border-b border-n-weak">
<woot-tabs :index="activeTab" :border="false" @change="onTabChange">
<woot-tabs-item
v-for="(item, index) in tabs"
@@ -41,13 +40,8 @@ export default {
:index="index"
:name="item.name"
:count="item.count"
:show-badge="item.showBadge"
/>
</woot-tabs>
</div>
</template>
<style lang="scss" scoped>
.tab-container {
@apply mt-1 border-b border-solid border-slate-100 dark:border-slate-800/50;
}
</style>
@@ -1,12 +1,9 @@
<script>
import SearchHeader from './SearchHeader.vue';
import SearchTabs from './SearchTabs.vue';
import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useTrack } from 'dashboard/composables';
import Policy from 'dashboard/components/policy.vue';
import { useI18n } from 'vue-i18n';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -16,197 +13,233 @@ import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { mapGetters } from 'vuex';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
components: {
SearchHeader,
SearchTabs,
SearchResultContactsList,
SearchResultConversationsList,
SearchResultMessagesList,
Policy,
ButtonV4,
},
data() {
return {
selectedTab: 'all',
query: '',
contactPermissions: CONTACT_PERMISSIONS,
conversationPermissions: CONVERSATION_PERMISSIONS,
rolePermissions: ROLES,
};
},
import Policy from 'dashboard/components/policy.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SearchHeader from './SearchHeader.vue';
import SearchTabs from './SearchTabs.vue';
import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
contactRecords: 'conversationSearch/getContactRecords',
conversationRecords: 'conversationSearch/getConversationRecords',
messageRecords: 'conversationSearch/getMessageRecords',
uiFlags: 'conversationSearch/getUIFlags',
}),
contacts() {
return this.contactRecords.map(contact => ({
...contact,
type: 'contact',
}));
},
conversations() {
return this.conversationRecords.map(conversation => ({
...conversation,
type: 'conversation',
}));
},
messages() {
return this.messageRecords.map(message => ({
...message,
type: 'message',
}));
},
all() {
return [...this.contacts, ...this.conversations, ...this.messages];
},
filterContacts() {
return this.selectedTab === 'contacts' || this.isSelectedTabAll;
},
filterConversations() {
return this.selectedTab === 'conversations' || this.isSelectedTabAll;
},
filterMessages() {
return this.selectedTab === 'messages' || this.isSelectedTabAll;
},
userPermissions() {
return getUserPermissions(this.currentUser, this.currentAccountId);
},
totalSearchResultsCount() {
const permissionCounts = {
contacts: {
permissions: [...this.rolePermissions, this.contactPermissions],
count: () => this.contacts.length,
},
conversations: {
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
count: () => this.conversations.length + this.messages.length,
},
};
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const filteredCounts = filterItemsByPermission(
permissionCounts,
this.userPermissions,
item => item.permissions,
(_, item) => item.count
);
const PER_PAGE = 15; // Results per page
const selectedTab = ref('all');
const query = ref('');
const pages = ref({
contacts: 1,
conversations: 1,
messages: 1,
});
return filteredCounts.reduce((total, count) => total + count(), 0);
},
tabs() {
const allTabsConfig = {
all: {
key: 'all',
name: this.$t('SEARCH.TABS.ALL'),
count: this.totalSearchResultsCount,
permissions: [
this.contactPermissions,
...this.rolePermissions,
...this.conversationPermissions,
],
},
contacts: {
key: 'contacts',
name: this.$t('SEARCH.TABS.CONTACTS'),
count: this.contacts.length,
permissions: [...this.rolePermissions, this.contactPermissions],
},
conversations: {
key: 'conversations',
name: this.$t('SEARCH.TABS.CONVERSATIONS'),
count: this.conversations.length,
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
},
messages: {
key: 'messages',
name: this.$t('SEARCH.TABS.MESSAGES'),
count: this.messages.length,
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
},
};
const currentUser = useMapGetter('getCurrentUser');
const currentAccountId = useMapGetter('getCurrentAccountId');
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords'
);
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
return filterItemsByPermission(
allTabsConfig,
this.userPermissions,
item => item.permissions
);
},
activeTabIndex() {
const index = this.tabs.findIndex(tab => tab.key === this.selectedTab);
return index >= 0 ? index : 0;
},
showEmptySearchResults() {
return (
this.totalSearchResultsCount === 0 &&
this.uiFlags.isSearchCompleted &&
!this.uiFlags.isFetching &&
this.query
);
},
showResultsSection() {
return (
(this.uiFlags.isSearchCompleted &&
this.totalSearchResultsCount !== 0) ||
this.uiFlags.isFetching
);
},
isSelectedTabAll() {
return this.selectedTab === 'all';
},
const addTypeToRecords = (records, type) =>
records.value.map(item => ({ ...item, type }));
const mappedContacts = computed(() =>
addTypeToRecords(contactRecords, 'contact')
);
const mappedConversations = computed(() =>
addTypeToRecords(conversationRecords, 'conversation')
);
const mappedMessages = computed(() =>
addTypeToRecords(messageRecords, 'message')
);
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
const sliceRecordsIfAllTab = items =>
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
const filterByTab = tab =>
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
const filterContacts = filterByTab('contacts');
const filterConversations = filterByTab('conversations');
const filterMessages = filterByTab('messages');
const userPermissions = computed(() =>
getUserPermissions(currentUser.value, currentAccountId.value)
);
const TABS_CONFIG = {
all: {
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => null, // No count for all tab
},
unmounted() {
this.query = '';
this.$store.dispatch('conversationSearch/clearSearchResults');
contacts: {
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => mappedContacts.value.length,
},
mounted() {
this.$store.dispatch('conversationSearch/clearSearchResults');
conversations: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedConversations.value.length,
},
methods: {
onSearch(q) {
this.selectedTab = 'all';
this.query = q;
if (!q) {
this.$store.dispatch('conversationSearch/clearSearchResults');
return;
}
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
this.$store.dispatch('conversationSearch/fullSearch', { q });
},
onBack() {
if (window.history.length > 2) {
this.$router.go(-1);
} else {
this.$router.push({ name: 'home' });
}
},
messages: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedMessages.value.length,
},
};
const tabs = computed(() => {
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
key,
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
count: config.count(),
showBadge: key !== 'all',
permissions: config.permissions,
}));
return filterItemsByPermission(
configs,
userPermissions.value,
item => item.permissions
);
});
const totalSearchResultsCount = computed(() => {
const permissionCounts = {
contacts: {
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => contacts.value.length,
},
conversations: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length,
},
};
return filterItemsByPermission(
permissionCounts,
userPermissions.value,
item => item.permissions,
(_, item) => item.count
).reduce((total, count) => total + count(), 0);
});
const activeTabIndex = computed(() => {
const index = tabs.value.findIndex(tab => tab.key === selectedTab.value);
return index >= 0 ? index : 0;
});
const isFetchingAny = computed(() => {
const { contact, message, conversation, isFetching } = uiFlags.value;
return (
isFetching ||
contact.isFetching ||
message.isFetching ||
conversation.isFetching
);
});
const showEmptySearchResults = computed(
() =>
totalSearchResultsCount.value === 0 &&
uiFlags.value.isSearchCompleted &&
isSelectedTabAll.value &&
!isFetchingAny.value &&
query.value
);
const showResultsSection = computed(
() =>
(uiFlags.value.isSearchCompleted && totalSearchResultsCount.value !== 0) ||
isFetchingAny.value ||
(!isSelectedTabAll.value && query.value && !isFetchingAny.value)
);
const showLoadMore = computed(() => {
if (!query.value || isFetchingAny.value || selectedTab.value === 'all')
return false;
const records = {
contacts: mappedContacts.value,
conversations: mappedConversations.value,
messages: mappedMessages.value,
}[selectedTab.value];
return (
records?.length > 0 &&
records.length === pages.value[selectedTab.value] * PER_PAGE
);
});
const showViewMore = computed(() => ({
// Hide view more button if the number of records is less than 5
contacts: mappedContacts.value?.length > 5 && isSelectedTabAll.value,
conversations:
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
}));
const clearSearchResult = () => {
pages.value = { contacts: 1, conversations: 1, messages: 1 };
store.dispatch('conversationSearch/clearSearchResults');
};
const onSearch = q => {
query.value = q;
clearSearchResult();
if (!q) return;
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
store.dispatch('conversationSearch/fullSearch', { q, page: 1 });
};
const onBack = () => {
if (window.history.length > 2) {
router.go(-1);
} else {
router.push({ name: 'home' });
}
clearSearchResult();
};
const loadMore = () => {
const SEARCH_ACTIONS = {
contacts: 'conversationSearch/contactSearch',
conversations: 'conversationSearch/conversationSearch',
messages: 'conversationSearch/messageSearch',
};
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
const tab = selectedTab.value;
pages.value[tab] += 1;
store.dispatch(SEARCH_ACTIONS[tab], {
q: query.value,
page: pages.value[tab],
});
};
onMounted(() => {
store.dispatch('conversationSearch/clearSearchResults');
});
onUnmounted(() => {
query.value = '';
store.dispatch('conversationSearch/clearSearchResults');
});
</script>
<template>
<div class="flex flex-col w-full bg-n-background">
<div class="flex p-4">
<ButtonV4
:label="$t('GENERAL_SETTINGS.BACK')"
<div class="flex flex-col w-full h-full bg-n-background">
<div class="flex w-full p-4">
<NextButton
:label="t('GENERAL_SETTINGS.BACK')"
icon="i-lucide-chevron-left"
faded
primary
@@ -214,73 +247,119 @@ export default {
@click="onBack"
/>
</div>
<section
class="flex my-0 p-4 relative mx-auto max-w-[45rem] min-h-[20rem] flex-col w-full h-full bg-n-background"
>
<header>
<SearchHeader @search="onSearch" />
<SearchTabs
v-if="query"
:tabs="tabs"
:selected-tab="activeTabIndex"
@tab-change="tab => (selectedTab = tab)"
/>
</header>
<div class="flex-grow h-full px-2 py-0 overflow-y-auto">
<div v-if="showResultsSection">
<Policy :permissions="[...rolePermissions, contactPermissions]">
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-4xl mx-auto">
<div class="flex flex-col w-full px-4">
<SearchHeader @search="onSearch" />
<SearchTabs
v-if="query"
:tabs="tabs"
:selected-tab="activeTabIndex"
@tab-change="tab => (selectedTab = tab)"
/>
</div>
<div
v-else-if="showEmptySearchResults"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="m-2 text-center text-n-slate-11">
{{ $t('SEARCH.EMPTY_STATE_FULL', { query }) }}
</p>
</div>
<div
v-else
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
>
<p class="text-center margin-bottom-0">
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
</p>
<p class="m-2 text-center text-n-slate-11">
{{ $t('SEARCH.EMPTY_STATE_DEFAULT') }}
</p>
</div>
<div class="flex-grow w-full h-full overflow-y-auto">
<div class="w-full max-w-4xl mx-auto px-4 pb-6">
<div v-if="showResultsSection">
<Policy
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.contacts"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'contacts'"
/>
</Policy>
<Policy
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.messages"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'messages'"
/>
</Policy>
<Policy
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.conversations"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'conversations'"
/>
</Policy>
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
<NextButton
v-if="!isSelectedTabAll"
:label="t(`SEARCH.LOAD_MORE`)"
icon="i-lucide-cloud-download"
slate
sm
faded
@click="loadMore"
/>
</div>
</div>
<div
v-else-if="showEmptySearchResults"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="m-2 text-center text-n-slate-11">
{{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
</p>
</div>
<div
v-else-if="!query"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
>
<p class="text-center margin-bottom-0">
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
</p>
<p class="m-2 text-center text-n-slate-11">
{{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
</p>
</div>
</div>
</div>
</section>
@@ -1,214 +1,17 @@
<script>
import { mapGetters } from 'vuex';
import AgentTable from './components/overview/AgentTable.vue';
import MetricCard from './components/overview/MetricCard.vue';
import { OVERVIEW_METRICS } from './constants';
import ReportHeatmap from './components/Heatmap.vue';
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
<script setup>
import ReportHeader from './components/ReportHeader.vue';
export const FETCH_INTERVAL = 60000;
export default {
name: 'LiveReports',
components: {
ReportHeader,
AgentTable,
MetricCard,
ReportHeatmap,
},
data() {
return {
// always start with 0, this is to manage the pagination in tanstack table
// when we send the data, we do a +1 to this value
pageIndex: 0,
};
},
computed: {
...mapGetters({
agentStatus: 'agents/getAgentStatus',
agents: 'agents/getAgents',
accountConversationMetric: 'getAccountConversationMetric',
agentConversationMetric: 'getAgentConversationMetric',
accountConversationHeatmap: 'getAccountConversationHeatmapData',
uiFlags: 'getOverviewUIFlags',
}),
agentStatusMetrics() {
let metric = {};
Object.keys(this.agentStatus).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.agentStatus[key];
});
return metric;
},
conversationMetrics() {
let metric = {};
Object.keys(this.accountConversationMetric).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.accountConversationMetric[key];
});
return metric;
},
},
mounted() {
this.$store.dispatch('agents/get');
this.initalizeReport();
},
beforeUnmount() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
},
methods: {
initalizeReport() {
this.fetchAllData();
this.scheduleReportRefresh();
},
scheduleReportRefresh() {
this.timeoutId = setTimeout(async () => {
await this.fetchAllData();
this.scheduleReportRefresh();
}, FETCH_INTERVAL);
},
fetchAllData() {
this.fetchAccountConversationMetric();
this.fetchAgentConversationMetric();
this.fetchHeatmapData();
},
downloadHeatmapData() {
let to = endOfDay(new Date());
this.$store.dispatch('downloadAccountConversationHeatmap', {
to: getUnixTime(to),
});
},
fetchHeatmapData() {
if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
return;
}
// the data for the last 6 days won't ever change,
// so there's no need to fetch it again
// but we can write some logic to check if the data is already there
// if it is there, we can refetch data only for today all over again
// and reconcile it with the rest of the data
// this will reduce the load on the server doing number crunching
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, 6));
if (this.accountConversationHeatmap.length) {
to = endOfDay(new Date());
from = startOfDay(to);
}
this.$store.dispatch('fetchAccountConversationHeatmap', {
metric: 'conversations_count',
from: getUnixTime(from),
to: getUnixTime(to),
groupBy: 'hour',
businessHours: false,
});
},
fetchAccountConversationMetric() {
this.$store.dispatch('fetchAccountConversationMetric', {
type: 'account',
});
},
fetchAgentConversationMetric() {
this.$store.dispatch('fetchAgentConversationMetric', {
type: 'agent',
page: this.pageIndex + 1,
});
},
onPageNumberChange(pageIndex) {
this.pageIndex = pageIndex;
this.fetchAgentConversationMetric();
},
},
};
import HeatmapContainer from './components/HeatmapContainer.vue';
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
</script>
<template>
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
<div class="flex flex-col gap-4 pb-6">
<div class="flex flex-col items-center md:flex-row gap-4">
<div
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
>
<MetricCard
:header="$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
:loading-message="
$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.LOADING_MESSAGE')
"
>
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
<div
v-for="(metric, name, index) in agentStatusMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
</div>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
<template #control>
<woot-button
icon="arrow-download"
size="small"
variant="smooth"
color-scheme="secondary"
@click="downloadHeatmapData"
>
{{ $t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT') }}
</woot-button>
</template>
<ReportHeatmap
:heat-data="accountConversationHeatmap"
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
/>
</MetricCard>
</div>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
<AgentTable
:agents="agents"
:agent-metrics="agentConversationMetric"
:page-index="pageIndex"
:is-loading="uiFlags.isFetchingAgentConversationMetric"
@page-change="onPageNumberChange"
/>
</MetricCard>
</div>
<StatsLiveReportsContainer />
<HeatmapContainer />
<AgentLiveReportContainer />
<TeamLiveReportContainer />
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { onMounted } from 'vue';
import AgentTable from './overview/AgentTable.vue';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const agentConversationMetric = useMapGetter('getAgentConversationMetric');
const agents = useMapGetter('agents/getAgents');
const fetchData = () => store.dispatch('fetchAgentConversationMetric');
const { startRefetching } = useLiveRefresh(fetchData);
onMounted(() => {
store.dispatch('agents/get');
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
<AgentTable
:agents="agents"
:agent-metrics="agentConversationMetric"
:is-loading="uiFlags.isFetchingAgentConversationMetric"
/>
</MetricCard>
</div>
</template>
@@ -10,10 +10,14 @@ import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
import { useI18n } from 'vue-i18n';
const props = defineProps({
heatData: {
heatmapData: {
type: Array,
default: () => [],
},
numberOfRows: {
type: Number,
default: 7,
},
isLoading: {
type: Boolean,
default: false,
@@ -21,11 +25,11 @@ const props = defineProps({
});
const { t } = useI18n();
const processedData = computed(() => {
return groupHeatmapByDay(props.heatData);
return groupHeatmapByDay(props.heatmapData);
});
const quantileRange = computed(() => {
const flattendedData = props.heatData.map(data => data.value);
const flattendedData = props.heatmapData.map(data => data.value);
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
});
@@ -95,14 +99,14 @@ function getHeatmapLevelClass(value) {
<template v-if="isLoading">
<div class="grid gap-[5px] flex-shrink-0">
<div
v-for="ii in 7"
v-for="ii in numberOfRows"
:key="ii"
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
/>
</div>
<div class="grid gap-[5px] w-full min-w-[700px]">
<div
v-for="ii in 7"
v-for="ii in numberOfRows"
:key="ii"
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
>
@@ -0,0 +1,119 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToggle } from '@vueuse/core';
import MetricCard from './overview/MetricCard.vue';
import ReportHeatmap from './Heatmap.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const accountConversationHeatmap = useMapGetter(
'getAccountConversationHeatmapData'
);
const { t } = useI18n();
const menuItems = [
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
value: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 29,
},
];
const selectedDays = ref(6);
const selectedDayFilter = computed(() =>
menuItems.find(menuItem => menuItem.value === selectedDays.value)
);
const downloadHeatmapData = () => {
const to = endOfDay(new Date());
store.dispatch('downloadAccountConversationHeatmap', {
daysBefore: selectedDays.value,
to: getUnixTime(to),
});
};
const [showDropdown, toggleDropdown] = useToggle();
const fetchHeatmapData = () => {
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
return;
}
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, Number(selectedDays.value)));
store.dispatch('fetchAccountConversationHeatmap', {
metric: 'conversations_count',
from: getUnixTime(from),
to: getUnixTime(to),
groupBy: 'hour',
businessHours: false,
});
};
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedDays.value = value;
fetchHeatmapData();
};
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
onMounted(() => {
fetchHeatmapData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
<template #control>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedDayFilter.label"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
<Button
sm
slate
faded
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
class="rounded-md group-hover:bg-n-alpha-2"
@click="downloadHeatmapData"
/>
</template>
<ReportHeatmap
:heatmap-data="accountConversationHeatmap"
:number-of-rows="selectedDays + 1"
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
/>
</MetricCard>
</div>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { OVERVIEW_METRICS } from '../constants';
import { useToggle } from '@vueuse/core';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const uiFlags = useMapGetter('getOverviewUIFlags');
const agentStatus = useMapGetter('agents/getAgentStatus');
const accountConversationMetric = useMapGetter('getAccountConversationMetric');
const store = useStore();
const accounti18nKey = 'OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS';
const teams = useMapGetter('teams/getTeams');
const teamMenuList = computed(() => {
return [
{ label: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.ALL_TEAMS'), value: null },
...teams.value.map(team => ({ label: team.name, value: team.id })),
];
});
const agentStatusMetrics = computed(() => {
let metric = {};
Object.keys(agentStatus.value).forEach(key => {
const metricName = t(
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = agentStatus.value[key];
});
return metric;
});
const conversationMetrics = computed(() => {
let metric = {};
Object.keys(accountConversationMetric.value).forEach(key => {
const metricName = t(`${accounti18nKey}.${OVERVIEW_METRICS[key]}`);
metric[metricName] = accountConversationMetric.value[key];
});
return metric;
});
const selectedTeam = ref(null);
const selectedTeamLabel = computed(() => {
const team =
teamMenuList.value.find(
menuItem => menuItem.value === selectedTeam.value
) || {};
return team.label;
});
const fetchData = () => {
const params = {};
if (selectedTeam.value) {
params.team_id = selectedTeam.value;
}
store.dispatch('fetchAccountConversationMetric', params);
};
const { startRefetching } = useLiveRefresh(fetchData);
const [showDropdown, toggleDropdown] = useToggle();
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedTeam.value = value;
fetchData();
};
onMounted(() => {
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-col items-center md:flex-row gap-4">
<div
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
>
<MetricCard
:header="t(`${accounti18nKey}.HEADER`)"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
:loading-message="t(`${accounti18nKey}.LOADING_MESSAGE`)"
>
<template v-if="teams.length" #control>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group z-50"
>
<Button
sm
slate
faded
:label="selectedTeamLabel"
class="capitalize rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="teamMenuList"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
label-class="capitalize"
@action="handleAction($event)"
/>
</div>
</template>
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
<div
v-for="(metric, name, index) in agentStatusMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { onMounted } from 'vue';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import TeamTable from './overview/TeamTable.vue';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const teamConversationMetric = useMapGetter('getTeamConversationMetric');
const teams = useMapGetter('teams/getTeams');
const fetchData = () => store.dispatch('fetchTeamConversationMetric');
const { startRefetching } = useLiveRefresh(fetchData);
onMounted(() => {
store.dispatch('teams/get');
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.HEADER')">
<TeamTable
:teams="teams"
:team-metrics="teamConversationMetric"
:is-loading="uiFlags.isFetchingTeamConversationMetric"
/>
</MetricCard>
</div>
</template>
@@ -4,6 +4,7 @@ import {
useVueTable,
createColumnHelper,
getCoreRowModel,
getPaginationRowModel,
} from '@tanstack/vue-table';
import { useI18n } from 'vue-i18n';
@@ -13,7 +14,7 @@ import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
import AgentCell from './AgentCell.vue';
const { agents, agentMetrics, pageIndex } = defineProps({
const { agents, agentMetrics } = defineProps({
agents: {
type: Array,
default: () => [],
@@ -26,42 +27,45 @@ const { agents, agentMetrics, pageIndex } = defineProps({
type: Boolean,
default: false,
},
pageIndex: {
type: Number,
default: 1,
},
});
const emit = defineEmits(['pageChange']);
const { t } = useI18n();
function getAgentInformation(id) {
return agents?.find(agent => agent.id === Number(id));
}
const getAgentMetrics = id =>
agentMetrics.find(metrics => metrics.assignee_id === Number(id)) || {};
const totalCount = computed(() => agents.length);
const tableData = computed(() => {
return agentMetrics
.filter(agentMetric => getAgentInformation(agentMetric.id))
const tableData = computed(() =>
agents
.map(agent => {
const agentInformation = getAgentInformation(agent.id);
const metric = getAgentMetrics(agent.id);
return {
agent: agentInformation.name || agentInformation.available_name,
email: agentInformation.email,
thumbnail: agentInformation.thumbnail,
open: agent.metric.open ?? 0,
unattended: agent.metric.unattended ?? 0,
status: agentInformation.availability_status,
agent: agent.available_name || agent.name,
email: agent.email,
thumbnail: agent.thumbnail,
open: metric.open || 0,
unattended: metric.unattended || 0,
status: agent.availability_status,
};
});
});
})
.sort((a, b) => {
// First sort by open tickets (descending)
const openDiff = b.open - a.open;
// If open tickets are equal, sort by name (ascending)
if (openDiff === 0) {
return a.agent.localeCompare(b.agent);
}
return openDiff;
})
);
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
class: cellProps.getValue()
? 'capitalize text-n-slate-12'
: 'capitalize text-n-slate-11',
},
cellProps.getValue() ? cellProps.getValue() : '---'
);
@@ -86,100 +90,33 @@ const columns = [
}),
];
const paginationParams = computed(() => {
return {
pageIndex: pageIndex,
pageSize: 25,
};
});
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
manualPagination: true,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
get rowCount() {
return totalCount.value;
},
state: {
get pagination() {
return paginationParams.value;
},
},
onPaginationChange: updater => {
const newPagintaion = updater(paginationParams.value);
emit('pageChange', newPagintaion.pageIndex);
},
getPaginationRowModel: getPaginationRowModel(),
});
</script>
<template>
<div class="agent-table-container">
<div class="flex flex-col flex-1">
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<Pagination class="mt-2" :table="table" />
<div v-if="isLoading" class="agents-loader">
<div
v-if="isLoading"
class="items-center flex text-base justify-center p-8"
>
<Spinner />
<span>{{
$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE')
}}</span>
<span>
{{ $t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE') }}
</span>
</div>
<EmptyState
v-else-if="!isLoading && !agentMetrics.length"
v-else-if="!isLoading && !agents.length"
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
/>
</div>
</template>
<style lang="scss" scoped>
.agent-table-container {
@apply flex flex-col flex-1;
.ve-table {
&::v-deep {
th.ve-table-header-th {
@apply text-sm rounded-xl;
padding: var(--space-small) var(--space-two) !important;
}
td.ve-table-body-td {
padding: var(--space-one) var(--space-two) !important;
}
}
}
&::v-deep .ve-pagination {
@apply bg-transparent dark:bg-transparent;
}
&::v-deep .ve-pagination-select {
@apply hidden;
}
.row-user-block {
@apply items-center flex text-left;
.user-block {
@apply items-start flex flex-col min-w-0 my-0 mx-2;
.title {
@apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100;
}
.sub-title {
@apply text-xs text-slate-600 dark:text-slate-200;
}
}
}
.table-pagination {
@apply mt-4 text-right;
}
}
.agents-loader {
@apply items-center flex text-base justify-center p-8;
}
</style>
@@ -1,31 +1,25 @@
<script>
<script setup>
import Spinner from 'shared/components/Spinner.vue';
export default {
name: 'MetricCard',
components: {
Spinner,
defineProps({
header: {
type: String,
default: '',
},
props: {
header: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
};
loadingMessage: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex flex-col m-0.5 px-6 py-5 overflow-hidden rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
class="flex flex-col m-0.5 px-6 py-5 rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
>
<div
class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2"
@@ -46,9 +40,7 @@ export default {
</span>
</span>
</div>
<div
class="transition-opacity duration-200 ease-in-out opacity-20 hover:opacity-100 flex flex-row items-center justify-end gap-2"
>
<div class="flex flex-row items-center justify-end gap-2">
<slot name="control" />
</div>
</slot>
@@ -0,0 +1,116 @@
<script setup>
import { computed, h } from 'vue';
import {
useVueTable,
createColumnHelper,
getCoreRowModel,
getPaginationRowModel,
} from '@tanstack/vue-table';
import { useI18n } from 'vue-i18n';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
const { teams, teamMetrics } = defineProps({
teams: {
type: Array,
default: () => [],
},
teamMetrics: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const getTeamMetrics = id =>
teamMetrics.find(metrics => metrics.team_id === Number(id)) || {};
const tableData = computed(() =>
teams
.map(team => {
const metric = getTeamMetrics(team.id);
return {
agent: team.name,
open: metric.open || 0,
unattended: metric.unattended || 0,
};
})
.sort((a, b) => {
// First sort by open tickets (descending)
const openDiff = b.open - a.open;
// If open tickets are equal, sort by name (ascending)
if (openDiff === 0) {
return a.agent.localeCompare(b.agent);
}
return openDiff;
})
);
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue()
? 'capitalize text-n-slate-12'
: 'capitalize text-n-slate-11',
},
cellProps.getValue() ? cellProps.getValue() : '---'
);
const columnHelper = createColumnHelper();
const columns = [
columnHelper.accessor('agent', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.TEAM'),
cell: defaulSpanRender,
size: 250,
}),
columnHelper.accessor('open', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.OPEN'),
cell: defaulSpanRender,
size: 100,
}),
columnHelper.accessor('unattended', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.UNATTENDED'),
cell: defaulSpanRender,
size: 100,
}),
];
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
</script>
<template>
<div class="flex flex-col flex-1">
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<Pagination class="mt-2" :table="table" />
<div
v-if="isLoading"
class="items-center flex text-base justify-center p-8"
>
<Spinner />
<span>
{{ $t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.LOADING_MESSAGE') }}
</span>
</div>
<EmptyState
v-else-if="!isLoading && !teams.length"
:title="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.NO_TEAMS')"
/>
</div>
</template>
@@ -75,11 +75,10 @@ export const actions = {
});
}
},
async contactSearch({ commit }, { q }) {
commit(types.CONTACT_SEARCH_SET, []);
async contactSearch({ commit }, { q, page = 1 }) {
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.contacts({ q });
const { data } = await SearchAPI.contacts({ q, page });
commit(types.CONTACT_SEARCH_SET, data.payload.contacts);
} catch (error) {
// Ignore error
@@ -87,11 +86,10 @@ export const actions = {
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async conversationSearch({ commit }, { q }) {
commit(types.CONVERSATION_SEARCH_SET, []);
async conversationSearch({ commit }, { q, page = 1 }) {
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.conversations({ q });
const { data } = await SearchAPI.conversations({ q, page });
commit(types.CONVERSATION_SEARCH_SET, data.payload.conversations);
} catch (error) {
// Ignore error
@@ -99,11 +97,10 @@ export const actions = {
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async messageSearch({ commit }, { q }) {
commit(types.MESSAGE_SEARCH_SET, []);
async messageSearch({ commit }, { q, page = 1 }) {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.messages({ q });
const { data } = await SearchAPI.messages({ q, page });
commit(types.MESSAGE_SEARCH_SET, data.payload.messages);
} catch (error) {
// Ignore error
@@ -112,9 +109,7 @@ export const actions = {
}
},
async clearSearchResults({ commit }) {
commit(types.MESSAGE_SEARCH_SET, []);
commit(types.CONVERSATION_SEARCH_SET, []);
commit(types.CONTACT_SEARCH_SET, []);
commit(types.CLEAR_SEARCH_RESULTS);
},
};
@@ -123,13 +118,13 @@ export const mutations = {
state.records = records;
},
[types.CONTACT_SEARCH_SET](state, records) {
state.contactRecords = records;
state.contactRecords = [...state.contactRecords, ...records];
},
[types.CONVERSATION_SEARCH_SET](state, records) {
state.conversationRecords = records;
state.conversationRecords = [...state.conversationRecords, ...records];
},
[types.MESSAGE_SEARCH_SET](state, records) {
state.messageRecords = records;
state.messageRecords = [...state.messageRecords, ...records];
},
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
state.uiFlags = { ...state.uiFlags, ...uiFlags };
@@ -146,6 +141,11 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
},
[types.CLEAR_SEARCH_RESULTS](state) {
state.contactRecords = [];
state.conversationRecords = [];
state.messageRecords = [];
},
};
export default {
@@ -4,10 +4,8 @@ import Report from '../../api/reports';
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
import {
reconcileHeatmapData,
clampDataBetweenTimeline,
} from 'shared/helpers/ReportsDataHelper';
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
import liveReports from '../../api/liveReports';
const state = {
fetchingStatus: false,
@@ -57,10 +55,12 @@ const state = {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
};
@@ -83,6 +83,9 @@ const getters = {
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
getTeamConversationMetric(_state) {
return _state.overview.teamConversationMetric;
},
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
@@ -114,11 +117,6 @@ export const actions = {
let { data } = heatmapData;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
data = reconcileHeatmapData(
data,
state.overview.accountConversationHeatmap
);
commit(types.default.SET_HEATMAP_DATA, data);
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
});
@@ -153,9 +151,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchAccountConversationMetric({ commit }, reportObj) {
fetchAccountConversationMetric({ commit }, params = {}) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type)
liveReports
.getConversationMetric(params)
.then(accountConversationMetric => {
commit(
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
@@ -167,9 +166,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchAgentConversationMetric({ commit }, reportObj) {
fetchAgentConversationMetric({ commit }) {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type, reportObj.page)
liveReports
.getGroupedConversations({ groupBy: 'assignee_id' })
.then(agentConversationMetric => {
commit(
types.default.SET_AGENT_CONVERSATION_METRIC,
@@ -181,6 +181,18 @@ export const actions = {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchTeamConversationMetric({ commit }) {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, true);
liveReports
.getGroupedConversations({ groupBy: 'team_id' })
.then(teamMetric => {
commit(types.default.SET_TEAM_CONVERSATION_METRIC, teamMetric.data);
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
});
},
downloadAgentReports(_, reportObj) {
return Report.getAgentReports(reportObj)
.then(response => {
@@ -234,7 +246,7 @@ export const actions = {
});
},
downloadAccountConversationHeatmap(_, reportObj) {
Report.getConversationTrafficCSV()
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
.then(response => {
downloadCsvFile(
generateFileName({
@@ -286,6 +298,12 @@ const mutations = {
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
},
[types.default.SET_TEAM_CONVERSATION_METRIC](_state, metricData) {
_state.overview.teamConversationMetric = metricData;
},
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
},
};
export default {
@@ -1,11 +1,19 @@
import { actions } from '../../conversationSearch';
import types from '../../../mutation-types';
import axios from 'axios';
const commit = vi.fn();
const dispatch = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
beforeEach(() => {
commit.mockClear();
dispatch.mockClear();
axios.get.mockClear();
});
describe('#get', () => {
it('sends correct actions if no query param is provided', () => {
actions.get({ commit }, { q: '' });
@@ -41,4 +49,111 @@ describe('#actions', () => {
]);
});
});
describe('#fullSearch', () => {
it('should not dispatch any actions if no query provided', async () => {
await actions.fullSearch({ commit, dispatch }, { q: '' });
expect(dispatch).not.toHaveBeenCalled();
});
it('should dispatch all search actions and set UI flags correctly', async () => {
await actions.fullSearch({ commit, dispatch }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[
types.FULL_SEARCH_SET_UI_FLAG,
{ isFetching: true, isSearchCompleted: false },
],
[
types.FULL_SEARCH_SET_UI_FLAG,
{ isFetching: false, isSearchCompleted: true },
],
]);
expect(dispatch).toHaveBeenCalledWith('contactSearch', { q: 'test' });
expect(dispatch).toHaveBeenCalledWith('conversationSearch', {
q: 'test',
});
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
});
});
describe('#contactSearch', () => {
it('should handle successful contact search', async () => {
axios.get.mockResolvedValue({
data: { payload: { contacts: [{ id: 1 }] } },
});
await actions.contactSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.CONTACT_SEARCH_SET, [{ id: 1 }]],
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed contact search', async () => {
axios.get.mockRejectedValue({});
await actions.contactSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#conversationSearch', () => {
it('should handle successful conversation search', async () => {
axios.get.mockResolvedValue({
data: { payload: { conversations: [{ id: 1 }] } },
});
await actions.conversationSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.CONVERSATION_SEARCH_SET, [{ id: 1 }]],
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed conversation search', async () => {
axios.get.mockRejectedValue({});
await actions.conversationSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#messageSearch', () => {
it('should handle successful message search', async () => {
axios.get.mockResolvedValue({
data: { payload: { messages: [{ id: 1 }] } },
});
await actions.messageSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.MESSAGE_SEARCH_SET, [{ id: 1 }]],
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed message search', async () => {
axios.get.mockRejectedValue({});
await actions.messageSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#clearSearchResults', () => {
it('should commit clear search results mutation', () => {
actions.clearSearchResults({ commit });
expect(commit).toHaveBeenCalledWith(types.CLEAR_SEARCH_RESULTS);
});
});
});
@@ -10,10 +10,49 @@ describe('#getters', () => {
]);
});
it('getContactRecords', () => {
const state = {
contactRecords: [{ id: 1, name: 'Contact 1' }],
};
expect(getters.getContactRecords(state)).toEqual([
{ id: 1, name: 'Contact 1' },
]);
});
it('getConversationRecords', () => {
const state = {
conversationRecords: [{ id: 1, title: 'Conversation 1' }],
};
expect(getters.getConversationRecords(state)).toEqual([
{ id: 1, title: 'Conversation 1' },
]);
});
it('getMessageRecords', () => {
const state = {
messageRecords: [{ id: 1, content: 'Message 1' }],
};
expect(getters.getMessageRecords(state)).toEqual([
{ id: 1, content: 'Message 1' },
]);
});
it('getUIFlags', () => {
const state = {
uiFlags: { isFetching: false },
uiFlags: {
isFetching: false,
isSearchCompleted: true,
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
},
};
expect(getters.getUIFlags(state)).toEqual({ isFetching: false });
expect(getters.getUIFlags(state)).toEqual({
isFetching: false,
isSearchCompleted: true,
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
});
});
});
@@ -10,7 +10,7 @@ describe('#mutations', () => {
});
});
describe('#SEARCH_CONVERSATIONS_SET', () => {
describe('#SEARCH_CONVERSATIONS_SET_UI_FLAG', () => {
it('set uiFlags correctly', () => {
const state = { uiFlags: { isFetching: true } };
mutations[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, {
@@ -19,4 +19,99 @@ describe('#mutations', () => {
expect(state.uiFlags).toEqual({ isFetching: false });
});
});
describe('#CONTACT_SEARCH_SET', () => {
it('should append new contact records to existing ones', () => {
const state = { contactRecords: [{ id: 1 }] };
mutations[types.CONTACT_SEARCH_SET](state, [{ id: 2 }]);
expect(state.contactRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#CONVERSATION_SEARCH_SET', () => {
it('should append new conversation records to existing ones', () => {
const state = { conversationRecords: [{ id: 1 }] };
mutations[types.CONVERSATION_SEARCH_SET](state, [{ id: 2 }]);
expect(state.conversationRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#MESSAGE_SEARCH_SET', () => {
it('should append new message records to existing ones', () => {
const state = { messageRecords: [{ id: 1 }] };
mutations[types.MESSAGE_SEARCH_SET](state, [{ id: 2 }]);
expect(state.messageRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#FULL_SEARCH_SET_UI_FLAG', () => {
it('set full search UI flags correctly', () => {
const state = {
uiFlags: {
isFetching: true,
isSearchCompleted: false,
},
};
mutations[types.FULL_SEARCH_SET_UI_FLAG](state, {
isFetching: false,
isSearchCompleted: true,
});
expect(state.uiFlags).toEqual({
isFetching: false,
isSearchCompleted: true,
});
});
});
describe('#CONTACT_SEARCH_SET_UI_FLAG', () => {
it('set contact search UI flags correctly', () => {
const state = {
uiFlags: {
contact: { isFetching: true },
},
};
mutations[types.CONTACT_SEARCH_SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags.contact).toEqual({ isFetching: false });
});
});
describe('#CONVERSATION_SEARCH_SET_UI_FLAG', () => {
it('set conversation search UI flags correctly', () => {
const state = {
uiFlags: {
conversation: { isFetching: true },
},
};
mutations[types.CONVERSATION_SEARCH_SET_UI_FLAG](state, {
isFetching: false,
});
expect(state.uiFlags.conversation).toEqual({ isFetching: false });
});
});
describe('#MESSAGE_SEARCH_SET_UI_FLAG', () => {
it('set message search UI flags correctly', () => {
const state = {
uiFlags: {
message: { isFetching: true },
},
};
mutations[types.MESSAGE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags.message).toEqual({ isFetching: false });
});
});
describe('#CLEAR_SEARCH_RESULTS', () => {
it('should clear all search records', () => {
const state = {
contactRecords: [{ id: 1 }],
conversationRecords: [{ id: 1 }],
messageRecords: [{ id: 1 }],
};
mutations[types.CLEAR_SEARCH_RESULTS](state);
expect(state.contactRecords).toEqual([]);
expect(state.conversationRecords).toEqual([]);
expect(state.messageRecords).toEqual([]);
});
});
});
@@ -311,6 +311,7 @@ export default {
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
@@ -334,4 +335,8 @@ export default {
SET_SLA_REPORTS: 'SET_SLA_REPORTS',
SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS',
SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META',
SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC',
TOGGLE_TEAM_CONVERSATION_METRIC_LOADING:
'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING',
};
+5 -3
View File
@@ -30,7 +30,8 @@ class SearchService
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
ILIKE :search OR contacts.phone_number ILIKE :search OR contacts.identifier ILIKE :search", search: "%#{search_query}%")
.order('conversations.created_at DESC')
.limit(10)
.page(params[:page])
.per(15)
end
def filter_messages
@@ -38,13 +39,14 @@ class SearchService
.where('messages.content ILIKE :search', search: "%#{search_query}%")
.where('created_at >= ?', 3.months.ago)
.reorder('created_at DESC')
.limit(10)
.page(params[:page])
.per(15)
end
def filter_contacts
@contacts = current_account.contacts.where(
"name ILIKE :search OR email ILIKE :search OR phone_number
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
).resolved_contacts.order_on_last_activity_at('desc').limit(10)
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
end
end
@@ -28,6 +28,12 @@ class Telegram::UpdateMessageService
end
def update_message
@message.update!(content: params[:edited_message][:text])
edited_message = params[:edited_message]
if edited_message[:text].present?
@message.update!(content: edited_message[:text])
elsif edited_message[:caption].present?
@message.update!(content: edited_message[:caption])
end
end
end
@@ -1,4 +1,4 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.agent_csv.agent_name'),
@@ -9,7 +9,7 @@
I18n.t('reports.agent_csv.resolution_count')
]
%>
<%= CSVSafe.generate_line headers -%>
<%= CSV.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
@@ -1,5 +1,5 @@
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
@@ -1,4 +1,4 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.inbox_csv.inbox_name'),
@@ -8,7 +8,7 @@
I18n.t('reports.inbox_csv.avg_resolution_time')
]
%>
<%= CSVSafe.generate_line headers -%>
<%= CSV.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
@@ -1,4 +1,4 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.label_csv.label_title'),
@@ -7,7 +7,7 @@
I18n.t('reports.label_csv.avg_resolution_time')
]
%>
<%= CSVSafe.generate_line headers -%>
<%= CSV.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
@@ -1,4 +1,4 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.team_csv.team_name'),
@@ -9,7 +9,7 @@
I18n.t('reports.team_csv.resolution_count')
]
%>
<%= CSVSafe.generate_line headers -%>
<%= CSV.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<%= CSV.generate_line row -%>
<% end %>
+6
View File
@@ -336,6 +336,12 @@ Rails.application.routes.draw do
get :bot_metrics
end
end
resources :live_reports, only: [] do
collection do
get :conversation_metrics
get :grouped_conversation_metrics
end
end
end
end
end
+5 -5
View File
@@ -177,7 +177,7 @@ function install_dependencies() {
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=20
NODE_MAJOR=23
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg 16" > /etc/apt/sources.list.d/pgdg.list
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
@@ -779,15 +779,15 @@ function upgrade_node() {
# Parse major version number
major_version=$(echo "$current_version" | cut -d. -f1)
if [ "$major_version" -ge 20 ]; then
echo "Node.js is already version $current_version (>= 20.x). Skipping Node.js upgrade."
if [ "$major_version" -ge 23 ]; then
echo "Node.js is already version $current_version (>= 23.x). Skipping Node.js upgrade."
return
fi
echo "Upgrading Node.js version to v20.x"
echo "Upgrading Node.js version to v23.x"
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=20
NODE_MAJOR=23
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
apt-get update
+31 -12
View File
@@ -1,6 +1,12 @@
# pre-build stage
FROM node:23-alpine as node
FROM ruby:3.3.3-alpine3.19 AS pre-builder
ARG NODE_VERSION="23.7.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
# ARG default to production settings
# For development docker-compose file overrides ARGS
ARG BUNDLE_WITHOUT="development:test"
@@ -25,18 +31,24 @@ RUN apk update && apk add --no-cache \
tzdata \
postgresql-dev \
postgresql-client \
nodejs=20.15.1-r0 \
git \
curl \
xz \
&& mkdir -p /var/app \
&& gem install bundler
# Install pnpm and configure environment
RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh - \
&& echo 'export PNPM_HOME="/root/.local/share/pnpm"' >> /root/.shrc \
&& echo 'export PATH="$PNPM_HOME:$PATH"' >> /root/.shrc \
&& export PNPM_HOME="/root/.local/share/pnpm" \
&& export PATH="$PNPM_HOME:$PATH" \
&& pnpm --version
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
RUN npm install -g pnpm@${PNPM_VERSION}
RUN echo 'export PNPM_HOME="/root/.local/share/pnpm"' >> /root/.shrc \
&& echo 'export PATH="$PNPM_HOME:$PATH"' >> /root/.shrc \
&& export PNPM_HOME="/root/.local/share/pnpm" \
&& export PATH="$PNPM_HOME:$PATH" \
&& pnpm --version
# Persist the environment variables in Docker
ENV PNPM_HOME="/root/.local/share/pnpm"
@@ -86,6 +98,10 @@ RUN rm -rf /gems/ruby/3.3.0/cache/*.gem \
# final build stage
FROM ruby:3.3.3-alpine3.19
ARG NODE_VERSION="23.7.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
@@ -114,11 +130,14 @@ RUN apk update && apk add --no-cache \
vips \
&& gem install bundler
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN if [ "$RAILS_ENV" != "production" ]; then \
apk add --no-cache nodejs=20.15.1-r0; \
# Install pnpm and configure environment
wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh - \
&& source /root/.shrc \
apk add --no-cache curl \
&& ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& npm install -g pnpm@${PNPM_VERSION} \
&& pnpm --version; \
fi
@@ -9,7 +9,13 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
def resolve_captain_conversations
CaptainInbox.all.find_each(batch_size: 100) do |captain_inbox|
Captain::InboxPendingConversationsResolutionJob.perform_later(captain_inbox.inbox)
inbox = captain_inbox.inbox
next if inbox.email?
Captain::InboxPendingConversationsResolutionJob.perform_later(
inbox
)
end
end
end
@@ -8,7 +8,11 @@ class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
@content = conversation.to_llm_text
end
# Generates and deduplicates FAQs from conversation content
# Skips processing if there was no human interaction
def generate_and_deduplicate
return [] if no_human_interaction?
new_faqs = generate
return [] if new_faqs.empty?
@@ -21,6 +25,10 @@ class Captain::Llm::ConversationFaqService < Captain::Llm::BaseOpenAiService
attr_reader :content, :conversation, :assistant
def no_human_interaction?
conversation.first_reply_created_at.nil?
end
def find_and_separate_duplicates(faqs)
duplicate_faqs = []
unique_faqs = []
+9 -8
View File
@@ -111,7 +111,7 @@
"@iconify-json/ri": "^1.2.3",
"@iconify-json/teenyicons": "^1.2.1",
"@size-limit/file": "^8.2.4",
"@vitest/coverage-v8": "2.0.1",
"@vitest/coverage-v8": "3.0.5",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
@@ -134,13 +134,13 @@
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8",
"vite": "^5.4.12",
"vite-plugin-ruby": "^5.0.0",
"vitest": "2.0.1"
"vitest": "3.0.5"
},
"engines": {
"node": "20.x",
"pnpm": "9.x"
"node": "23.x",
"pnpm": "10.x"
},
"husky": {
"hooks": {
@@ -150,8 +150,8 @@
"pnpm": {
"overrides": {
"vite-node": "2.0.1",
"vite": "5.4.8",
"vitest": "2.0.1"
"vite": "5.4.12",
"vitest": "3.0.5"
}
},
"lint-staged": {
@@ -162,5 +162,6 @@
"*.scss": [
"scss-lint"
]
}
},
"packageManager": "pnpm@10.2.0+sha512.0d27364e0139c6aadeed65ada153135e0ca96c8da42123bd50047f961339dc7a758fc2e944b428f52be570d1bd3372455c1c65fa2e7aa0bfbf931190f9552001"
}
+322 -367
View File
File diff suppressed because it is too large Load Diff
@@ -91,16 +91,15 @@ RSpec.describe 'Contacts API', type: :request do
end
it 'returns all contacts with country name desc order with null values at last' do
contact_from_albania = create(:contact, :with_email, account: account, additional_attributes: { country_code: 'AL', country: 'Albania' })
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=country",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
# TODO: this spec has been flaky for a while, so adding a debug statement to see the response
Rails.logger.info(response_body)
expect(response_body['payload'].first['email']).to eq(contact.email)
expect(response_body['payload'].first['id']).to eq(contact.id)
expect(response_body['payload'].first['email']).to eq(contact_from_albania.email)
expect(response_body['payload'].first['id']).to eq(contact_from_albania.id)
expect(response_body['payload'].last['email']).to eq(contact_4.email)
end
@@ -1,29 +1,44 @@
require 'rails_helper'
RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do
let!(:account_with_bot) { create(:account) }
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account_with_bot) }
let!(:account_without_bot) { create(:account) }
let!(:inbox_with_bot) { create(:inbox, account: account_with_bot) }
let!(:inbox_without_bot) { create(:inbox, account: account_without_bot) }
let(:assistant) { create(:captain_assistant, account: account) }
describe '#perform - captain resolutions' do
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox_with_bot)
context 'when handling different inbox types' do
let!(:regular_inbox) { create(:inbox, account: account) }
let!(:email_inbox) { create(:inbox, :with_email, account: account) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox)
create(:captain_inbox, captain_assistant: assistant, inbox: email_inbox)
end
it 'enqueues resolution jobs only for non-email inboxes with captain enabled' do
expect do
described_class.perform_now
end.to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
.with(regular_inbox)
.exactly(:once)
end
it 'does not enqueue resolution jobs for email inboxes even with captain enabled' do
expect do
described_class.perform_now
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
.with(email_inbox)
end
end
it 'enqueues resolution jobs only for inboxes with captain enabled' do
expect do
described_class.perform_now
end.to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob).with(inbox_with_bot).and have_enqueued_job.exactly(:once)
end
context 'when inbox has no captain enabled' do
let!(:inbox_without_captain) { create(:inbox, account: create(:account)) }
it 'does not enqueue resolution jobs for inboxes without captain enabled' do
expect do
described_class.perform_now
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob).with(inbox_without_bot)
it 'does not enqueue resolution jobs' do
expect do
described_class.perform_now
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
.with(inbox_without_captain)
end
end
end
end
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Captain::Llm::ConversationFaqService do
let(:captain_assistant) { create(:captain_assistant) }
let(:conversation) { create(:conversation) }
let(:conversation) { create(:conversation, first_reply_created_at: Time.zone.now) }
let(:service) { described_class.new(captain_assistant, conversation) }
let(:client) { instance_double(OpenAI::Client) }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
@@ -57,6 +57,14 @@ RSpec.describe Captain::Llm::ConversationFaqService do
end
end
context 'without human interaction' do
let(:conversation) { create(:conversation) }
it 'returns an empty array without generating FAQs' do
expect(service.generate_and_deduplicate).to eq([])
end
end
context 'when finding duplicates' do
let(:existing_response) do
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
+5
View File
@@ -9,5 +9,10 @@ FactoryBot.define do
after(:create) do |inbox|
inbox.channel.save!
end
trait :with_email do
channel { FactoryBot.build(:channel_email, account: account) }
name { 'Email Inbox' }
end
end
end
@@ -2,41 +2,57 @@ require 'rails_helper'
describe Telegram::UpdateMessageService do
let!(:telegram_channel) { create(:channel_telegram) }
let!(:update_params) do
let(:common_message_params) do
{
'update_id': 2_323_484,
'edited_message': {
'from': {
'id': 123,
'username': 'sojan'
},
'chat': {
'id': 789,
'type': 'private'
},
'date': Time.now.to_i,
'edit_date': Time.now.to_i
}
end
let(:text_update_params) do
{
'update_id': 1,
'edited_message': common_message_params.merge(
'message_id': 48,
'from': {
'id': 512_313_123_171_248,
'is_bot': false,
'first_name': 'Sojan',
'last_name': 'Jose',
'username': 'sojan'
},
'chat': {
'id': 517_123_213_211_248,
'first_name': 'Sojan',
'last_name': 'Jose',
'username': 'sojan',
'type': 'private'
},
'date': 1_680_088_034,
'edit_date': 1_680_088_056,
'text': 'updated message'
}
)
}
end
let(:caption_update_params) do
{
'update_id': 2,
'edited_message': common_message_params.merge(
'message_id': 49,
'caption': 'updated caption'
)
}
end
describe '#perform' do
context 'when valid update message params' do
it 'updates the appropriate message' do
contact_inbox = create(:contact_inbox, inbox: telegram_channel.inbox, source_id: update_params[:edited_message][:chat][:id])
conversation = create(:conversation, contact_inbox: contact_inbox)
message = create(:message, conversation: conversation, source_id: update_params[:edited_message][:message_id])
described_class.new(inbox: telegram_channel.inbox, params: update_params.with_indifferent_access).perform
let(:contact_inbox) { create(:contact_inbox, inbox: telegram_channel.inbox, source_id: common_message_params[:chat][:id]) }
let(:conversation) { create(:conversation, contact_inbox: contact_inbox) }
it 'updates the message text when text is present' do
message = create(:message, conversation: conversation, source_id: text_update_params[:edited_message][:message_id])
described_class.new(inbox: telegram_channel.inbox, params: text_update_params.with_indifferent_access).perform
expect(message.reload.content).to eq('updated message')
end
it 'updates the message caption when caption is present' do
message = create(:message, conversation: conversation, source_id: caption_update_params[:edited_message][:message_id])
described_class.new(inbox: telegram_channel.inbox, params: caption_update_params.with_indifferent_access).perform
expect(message.reload.content).to eq('updated caption')
end
end
context 'when invalid update message params' do