Compare commits

..
Author SHA1 Message Date
Pranav 4e919256e6 Update logic 2025-03-13 20:15:37 -07:00
Pranav 8ae7c15301 fix: Use inbox stream for conversation events instead of individual user stream 2025-03-13 18:08:39 -07:00
PranavandGitHub 325dc4a741 fix: Move contact events to account stream rather than individual user stream (#11082) 2025-03-13 17:46:48 -07:00
Sivin VargheseandGitHub a8001ccabc fix: Translate "None" option in automation select (#11076)
# Pull Request Template

## Description

This PR includes a translation update for the "None" option in the
automation select for both agents and teams

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-03-13 16:44:46 -07:00
Sivin VargheseandGitHub 557df5c5c9 fix: Wrong copy with teams multi-select dropdown (#11075) 2025-03-13 10:34:20 +05:30
PranavandGitHub cc4924db55 feat: Update translated content inline (#11074)
Previously, viewing translations required opening a modal, which was a
frustrating experience. This update improves the UX by displaying
translations inline.

https://www.loom.com/share/c69f4316248946208f2e56e2429063a2

A sample message with translated content (which has an option to see the
original):
 
<img width="313" alt="Screenshot 2025-03-12 at 6 57 49 PM"
src="https://github.com/user-attachments/assets/52434086-b57f-40e8-87b8-314c3519df4b"
/>

Right now, the translation is done based on the account locale. Some of
the future considerations.

- Add personal preference for the language (For eg: an agent can use the
software in spanish even though the company default is english), then
support translations based on personal preference.
- Add support for LLM translations (OpenAI integration)
- Add translations if Captain is enabled
- Add auto translation if the feature is turned on.
2025-03-12 19:31:28 -07:00
PranavandGitHub ae694da6c1 chore: Disable sending outgoing messages if the conversation is active (#11073)
At 5 PM, when business hours officially end, an automatic out-of-office
message is sent to customers. However, this creates a poor experience if
an agent is actively chatting with the customer. This update ensures
that the out-of-office message is only sent if no agent message has been
sent in the last 5 minutes. If the customer reaches out again after 5
minutes, the out-of-office message will be triggered.
2025-03-12 19:22:44 -07:00
PranavandGitHub 2024b9e90d chore: Update settings page to match the new design colors (#11072) 2025-03-12 18:11:42 -07:00
PranavandGitHub 7e54b13a8b feat: Add live report for teams (#10849) 2025-03-12 16:03:09 -07:00
29158e32fe chore: Logger for non-existent WhatsApp channels (#11064)
- Add a warning logger for cases where we are getting webhook events for
inactive numbers.
- Add config to discard events for inactive numbers so that the meta
will stop sending events

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-03-12 15:50:38 -07:00
Sojan JoseandGitHub d0631e99a5 chore: Add warning logs when Chatwoot receives events for inactive channels (#11066) 2025-03-12 08:16:47 -07:00
ba44f4fff8 chore: Update translations (#11067)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-03-12 16:38:25 +05:30
00556bd55a feat: ignore out of sync messages (#11058)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-03-12 14:48:05 +05:30
Sivin VargheseandGitHub 15353ee779 fix: Email rendering issue with Google Drive link (#11069) 2025-03-12 14:36:17 +05:30
5c05e45206 feat: use nocookie version of youtube & vimeo for help center embeds (#11061)
This pull request includes changes to the `CustomMarkdownRenderer` class
and its corresponding tests to support YouTube's "nocookie"
[[ref](https://support.google.com/youtube/answer/171780?hl=en#zippy=%2Cturn-on-privacy-enhanced-mode)]
URLs and adding the `dnt` param to Vimeo embed
[[ref](https://help.vimeo.com/hc/en-us/articles/12426260232977-About-Player-parameters)].

![CleanShot 2025-03-11 at 19 39
36@2x](https://github.com/user-attachments/assets/e7a3e9b1-35a6-4c8c-bdc2-005f773b3d04)

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-03-12 10:32:25 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
86b948ecbd chore(deps): Bump axios from 1.7.7 to 1.8.2 (#11042)
Bumps [axios](https://github.com/axios/axios) from 1.7.7 to 1.8.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/axios/axios/releases">axios's
releases</a>.</em></p>
<blockquote>
<h2>Release v1.8.2</h2>
<h2>Release notes:</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>http-adapter:</strong> add allowAbsoluteUrls to path
building (<a
href="https://redirect.github.com/axios/axios/issues/6810">#6810</a>)
(<a
href="https://github.com/axios/axios/commit/fb8eec214ce7744b5ca787f2c3b8339b2f54b00f">fb8eec2</a>)</li>
</ul>
<h3>Contributors to this release</h3>
<ul>
<li><!-- raw HTML omitted --> <a href="https://github.com/lexcorp16"
title="+1/-1 ([#6810](https://github.com/axios/axios/issues/6810)
)">Fasoro-Joseph Alexander</a></li>
</ul>
<h2>Release v1.8.1</h2>
<h2>Release notes:</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>utils:</strong> move <code>generateString</code> to platform
utils to avoid importing crypto module into client builds; (<a
href="https://redirect.github.com/axios/axios/issues/6789">#6789</a>)
(<a
href="https://github.com/axios/axios/commit/36a5a620bec0b181451927f13ac85b9888b86cec">36a5a62</a>)</li>
</ul>
<h3>Contributors to this release</h3>
<ul>
<li><!-- raw HTML omitted --> <a
href="https://github.com/DigitalBrainJS" title="+51/-47
([#6789](https://github.com/axios/axios/issues/6789) )">Dmitriy
Mozgovoy</a></li>
</ul>
<h2>Release v1.8.0</h2>
<h2>Release notes:</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>examples:</strong> application crashed when navigating
examples in browser (<a
href="https://redirect.github.com/axios/axios/issues/5938">#5938</a>)
(<a
href="https://github.com/axios/axios/commit/1260ded634ec101dd5ed05d3b70f8e8f899dba6c">1260ded</a>)</li>
<li>missing word in SUPPORT_QUESTION.yml (<a
href="https://redirect.github.com/axios/axios/issues/6757">#6757</a>)
(<a
href="https://github.com/axios/axios/commit/1f890b13f2c25a016f3c84ae78efb769f244133e">1f890b1</a>)</li>
<li><strong>utils:</strong> replace getRandomValues with crypto module
(<a
href="https://redirect.github.com/axios/axios/issues/6788">#6788</a>)
(<a
href="https://github.com/axios/axios/commit/23a25af0688d1db2c396deb09229d2271cc24f6c">23a25af</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>Add config for ignoring absolute URLs (<a
href="https://redirect.github.com/axios/axios/issues/5902">#5902</a>)
(<a
href="https://redirect.github.com/axios/axios/issues/6192">#6192</a>)
(<a
href="https://github.com/axios/axios/commit/32c7bcc0f233285ba27dec73a4b1e81fb7a219b3">32c7bcc</a>)</li>
</ul>
<h3>Reverts</h3>
<ul>
<li>Revert &quot;chore: expose fromDataToStream to be consumable (<a
href="https://redirect.github.com/axios/axios/issues/6731">#6731</a>)&quot;
(<a
href="https://redirect.github.com/axios/axios/issues/6732">#6732</a>)
(<a
href="https://github.com/axios/axios/commit/1317261125e9c419fe9f126867f64d28f9c1efda">1317261</a>),
closes <a
href="https://redirect.github.com/axios/axios/issues/6731">#6731</a> <a
href="https://redirect.github.com/axios/axios/issues/6732">#6732</a></li>
</ul>
<h3>BREAKING CHANGES</h3>
<ul>
<li>
<p>code relying on the above will now combine the URLs instead of prefer
request URL</p>
</li>
<li>
<p>feat: add config option for allowing absolute URLs</p>
</li>
<li>
<p>fix: add default value for allowAbsoluteUrls in buildFullPath</p>
</li>
<li>
<p>fix: typo in flow control when setting allowAbsoluteUrls</p>
</li>
</ul>
<h3>Contributors to this release</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/axios/axios/blob/v1.x/CHANGELOG.md">axios's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/axios/axios/compare/v1.8.1...v1.8.2">1.8.2</a>
(2025-03-07)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>http-adapter:</strong> add allowAbsoluteUrls to path
building (<a
href="https://redirect.github.com/axios/axios/issues/6810">#6810</a>)
(<a
href="https://github.com/axios/axios/commit/fb8eec214ce7744b5ca787f2c3b8339b2f54b00f">fb8eec2</a>)</li>
</ul>
<h3>Contributors to this release</h3>
<ul>
<li><!-- raw HTML omitted --> <a href="https://github.com/lexcorp16"
title="+1/-1 ([#6810](https://github.com/axios/axios/issues/6810)
)">Fasoro-Joseph Alexander</a></li>
</ul>
<h2><a
href="https://github.com/axios/axios/compare/v1.8.0...v1.8.1">1.8.1</a>
(2025-02-26)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>utils:</strong> move <code>generateString</code> to platform
utils to avoid importing crypto module into client builds; (<a
href="https://redirect.github.com/axios/axios/issues/6789">#6789</a>)
(<a
href="https://github.com/axios/axios/commit/36a5a620bec0b181451927f13ac85b9888b86cec">36a5a62</a>)</li>
</ul>
<h3>Contributors to this release</h3>
<ul>
<li><!-- raw HTML omitted --> <a
href="https://github.com/DigitalBrainJS" title="+51/-47
([#6789](https://github.com/axios/axios/issues/6789) )">Dmitriy
Mozgovoy</a></li>
</ul>
<h1><a
href="https://github.com/axios/axios/compare/v1.7.9...v1.8.0">1.8.0</a>
(2025-02-25)</h1>
<h3>Bug Fixes</h3>
<ul>
<li><strong>examples:</strong> application crashed when navigating
examples in browser (<a
href="https://redirect.github.com/axios/axios/issues/5938">#5938</a>)
(<a
href="https://github.com/axios/axios/commit/1260ded634ec101dd5ed05d3b70f8e8f899dba6c">1260ded</a>)</li>
<li>missing word in SUPPORT_QUESTION.yml (<a
href="https://redirect.github.com/axios/axios/issues/6757">#6757</a>)
(<a
href="https://github.com/axios/axios/commit/1f890b13f2c25a016f3c84ae78efb769f244133e">1f890b1</a>)</li>
<li><strong>utils:</strong> replace getRandomValues with crypto module
(<a
href="https://redirect.github.com/axios/axios/issues/6788">#6788</a>)
(<a
href="https://github.com/axios/axios/commit/23a25af0688d1db2c396deb09229d2271cc24f6c">23a25af</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>Add config for ignoring absolute URLs (<a
href="https://redirect.github.com/axios/axios/issues/5902">#5902</a>)
(<a
href="https://redirect.github.com/axios/axios/issues/6192">#6192</a>)
(<a
href="https://github.com/axios/axios/commit/32c7bcc0f233285ba27dec73a4b1e81fb7a219b3">32c7bcc</a>)</li>
</ul>
<h3>Reverts</h3>
<ul>
<li>Revert &quot;chore: expose fromDataToStream to be consumable (<a
href="https://redirect.github.com/axios/axios/issues/6731">#6731</a>)&quot;
(<a
href="https://redirect.github.com/axios/axios/issues/6732">#6732</a>)
(<a
href="https://github.com/axios/axios/commit/1317261125e9c419fe9f126867f64d28f9c1efda">1317261</a>),
closes <a
href="https://redirect.github.com/axios/axios/issues/6731">#6731</a> <a
href="https://redirect.github.com/axios/axios/issues/6732">#6732</a></li>
</ul>
<h3>BREAKING CHANGES</h3>
<ul>
<li>
<p>code relying on the above will now combine the URLs instead of prefer
request URL</p>
</li>
<li>
<p>feat: add config option for allowing absolute URLs</p>
</li>
<li>
<p>fix: add default value for allowAbsoluteUrls in buildFullPath</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/axios/axios/commit/a9f7689b0c4b6d68c7f587c3aa376860da509d94"><code>a9f7689</code></a>
chore(release): v1.8.2 (<a
href="https://redirect.github.com/axios/axios/issues/6812">#6812</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/fb8eec214ce7744b5ca787f2c3b8339b2f54b00f"><code>fb8eec2</code></a>
fix(http-adapter): add allowAbsoluteUrls to path building (<a
href="https://redirect.github.com/axios/axios/issues/6810">#6810</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/98120457559e573024862e2925d56295a965ad7e"><code>9812045</code></a>
chore(sponsor): update sponsor block (<a
href="https://redirect.github.com/axios/axios/issues/6804">#6804</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/72acf759373ef4e211d5299818d19e50e08c02f8"><code>72acf75</code></a>
chore(sponsor): update sponsor block (<a
href="https://redirect.github.com/axios/axios/issues/6794">#6794</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/2e64afdff5c41e38284a6fb8312f2745072513a1"><code>2e64afd</code></a>
chore(release): v1.8.1 (<a
href="https://redirect.github.com/axios/axios/issues/6800">#6800</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/36a5a620bec0b181451927f13ac85b9888b86cec"><code>36a5a62</code></a>
fix(utils): move <code>generateString</code> to platform utils to avoid
importing crypto...</li>
<li><a
href="https://github.com/axios/axios/commit/cceb7b1e154fbf294135c93d3f91921643bbe49f"><code>cceb7b1</code></a>
chore(release): v1.8.0 (<a
href="https://redirect.github.com/axios/axios/issues/6795">#6795</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/23a25af0688d1db2c396deb09229d2271cc24f6c"><code>23a25af</code></a>
fix(utils): replace getRandomValues with crypto module (<a
href="https://redirect.github.com/axios/axios/issues/6788">#6788</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/32c7bcc0f233285ba27dec73a4b1e81fb7a219b3"><code>32c7bcc</code></a>
feat: Add config for ignoring absolute URLs (<a
href="https://redirect.github.com/axios/axios/issues/5902">#5902</a>)
(<a
href="https://redirect.github.com/axios/axios/issues/6192">#6192</a>)</li>
<li><a
href="https://github.com/axios/axios/commit/4a3e26cf65bb040b7eb4577d5fd62199b0f3d017"><code>4a3e26c</code></a>
chore(config): adjust rollup config to preserve license header to
minified Ja...</li>
<li>Additional commits viewable in <a
href="https://github.com/axios/axios/compare/v1.7.7...v1.8.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=axios&package-manager=npm_and_yarn&previous-version=1.7.7&new-version=1.8.2)](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-03-11 15:23:55 -07:00
PranavandGitHub 2dd9f0f672 fix: Force re-render route i18n string (#11063)
The string were not taking the correct locale as there were initialized
during the component mount. This change would force the component to
re-render the strings.
2025-03-11 15:15:28 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Pranav
dedd67167a chore(deps): Bump rack from 2.2.12 to 2.2.13 (#11056)
Bumps [rack](https://github.com/rack/rack) from 2.2.12 to 2.2.13.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rack/rack/blob/main/CHANGELOG.md">rack's
changelog</a>.</em></p>
<blockquote>
<h2>[2.2.13] - 2025-03-11</h2>
<h3>Security</h3>
<ul>
<li><a
href="https://github.com/rack/rack/security/advisories/GHSA-7wqh-767x-r66v">CVE-2025-27610</a>
Local file inclusion in <code>Rack::Static</code>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rack/rack/commit/df6c47357f6c6bec2d585f45f417285d813d9b3a"><code>df6c473</code></a>
Bump patch verison.</li>
<li><a
href="https://github.com/rack/rack/commit/cceb70c72022503115b8d4d95d1037caf8aba73c"><code>cceb70c</code></a>
Update changelog.</li>
<li><a
href="https://github.com/rack/rack/commit/873d39e6d673a91fbdeb2ec75d42184d35908bb4"><code>873d39e</code></a>
Use a fully resolved file path when confirming if a file can be served
by `Ra...</li>
<li>See full diff in <a
href="https://github.com/rack/rack/compare/v2.2.12...v2.2.13">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rack&package-manager=bundler&previous-version=2.2.12&new-version=2.2.13)](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>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-03-11 15:02:41 -07:00
Sivin VargheseandGitHub d96ac59cca fix: Translate "None" option in agent assignment dropdown (#11060)
# Pull Request Template

## Description

This PR includes a translation update for the "None" option in the agent
assignment multi-select dropdown.

Fixes
https://linear.app/chatwoot/issue/CW-4140/none-option-in-assign-agent-multi-select-is-not-translated

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Test cases**
1. Check in conversation sidebar
2. Check in command bar
3. Check in participation dropdown


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-03-11 14:49:27 -07:00
116 changed files with 1639 additions and 1047 deletions
+1 -1
View File
@@ -561,7 +561,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.12)
rack (2.2.13)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
+20 -8
View File
@@ -2,10 +2,9 @@ class RoomChannel < ApplicationCable::Channel
def subscribed
# TODO: should we only do ensure stream if current account is present?
# for now going ahead with guard clauses in update_subscription and broadcast_presence
ensure_stream
current_user
current_account
prepare_stream
update_subscription
broadcast_presence
end
@@ -22,12 +21,21 @@ class RoomChannel < ApplicationCable::Channel
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
end
def ensure_stream
@pubsub_token = params[:pubsub_token]
stream_from @pubsub_token
def prepare_stream
stream_from pubsub_token
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
stream_from_inboxes
end
def stream_from_inboxes
return if current_user.blank?
current_user.assigned_inboxes.each do |inbox|
stream_from "inbox_#{inbox.id}"
end
end
def update_subscription
@@ -36,11 +44,15 @@ class RoomChannel < ApplicationCable::Channel
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end
def pubsub_token
@pubsub_token ||= params[:pubsub_token]
end
def current_user
@current_user ||= if params[:user_id].blank?
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
ContactInbox.find_by!(pubsub_token: pubsub_token).contact
else
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
end
end
@@ -2,6 +2,12 @@ class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload
if inactive_whatsapp_number?
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
return
end
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
@@ -13,4 +19,15 @@ class Webhooks::WhatsappController < ActionController::API
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
def inactive_whatsapp_number?
phone_number = params[:phone_number]
return false if phone_number.blank?
inactive_numbers = GlobalConfig.get_value('INACTIVE_WHATSAPP_NUMBERS').to_s
return false if inactive_numbers.blank?
inactive_numbers_array = inactive_numbers.split(',').map(&:strip)
inactive_numbers_array.include?(phone_number)
end
end
@@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) {
}
input[type='file'] {
@apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4;
@apply bg-n-background leading-[1.15] mb-4;
}
// Select
@@ -93,7 +93,7 @@ const hasQuotedMessage = computed(() => {
<template v-else>
<Letter
v-if="showQuotedMessage"
class-name="prose prose-bubble !max-w-none"
class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
@@ -104,7 +104,7 @@ const hasQuotedMessage = computed(() => {
/>
<Letter
v-else
class-name="prose prose-bubble !max-w-none"
class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML"
:allowed-css-properties="[
...allowedCssProperties,
@@ -143,3 +143,21 @@ const hasQuotedMessage = computed(() => {
</section>
</BaseBubble>
</template>
<style lang="scss">
// Tailwind resets break the rendering of google drive link in Gmail messages
// This fixes it using https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors
.letter-render [class*='gmail_drive_chip'] {
box-sizing: initial;
@apply bg-n-slate-4 border-n-slate-6 rounded-md !important;
a {
@apply text-n-slate-12 !important;
img {
display: inline-block;
}
}
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
@@ -9,6 +9,26 @@ import { useMessageContext } from '../../provider.js';
const { content, attachments, contentAttributes, messageType } =
useMessageContext();
const hasTranslations = computed(() => {
const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const renderOriginal = ref(false);
const renderContent = computed(() => {
if (renderOriginal.value) {
return content.value;
}
if (hasTranslations.value) {
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
}
return content.value;
});
const isTemplate = computed(() => {
return messageType.value === MESSAGE_TYPES.TEMPLATE;
});
@@ -16,6 +36,16 @@ const isTemplate = computed(() => {
const isEmpty = computed(() => {
return !content.value && !attachments.value?.length;
});
const viewToggleKey = computed(() => {
return renderOriginal.value
? 'CONVERSATION.VIEW_TRANSLATED'
: 'CONVERSATION.VIEW_ORIGINAL';
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script>
<template>
@@ -24,7 +54,16 @@ const isEmpty = computed(() => {
<span v-if="isEmpty" class="text-n-slate-11">
{{ $t('CONVERSATION.NO_CONTENT') }}
</span>
<FormattedContent v-if="content" :content="content" />
<FormattedContent v-if="renderContent" :content="renderContent" />
<span class="-mt-3">
<span
v-if="hasTranslations"
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
@click="handleSeeOriginal"
>
{{ $t(viewToggleKey) }}
</span>
</span>
<AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate">
<div
@@ -90,7 +90,7 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const newReportRoutes = [
const newReportRoutes = () => [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
@@ -116,7 +116,7 @@ const newReportRoutes = [
},
];
const oldReportRoutes = [
const oldReportRoutes = () => [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
@@ -140,7 +140,7 @@ const oldReportRoutes = [
];
const reportRoutes = computed(() =>
showV4Routes.value ? newReportRoutes : oldReportRoutes
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
);
const menuItems = computed(() => {
@@ -97,7 +97,7 @@ export default {
}
.step {
@apply bg-slate-75 dark:bg-slate-600 rounded-2xl font-medium w-4 left-4 leading-4 z-[999] absolute text-center text-white dark:text-white text-xxs top-5;
@apply bg-slate-75 dark:bg-slate-600 rounded-2xl font-medium w-4 left-4 leading-4 z-10 absolute text-center text-white dark:text-white text-xxs top-5;
}
}
}
@@ -1,5 +1,4 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import router from '../../routes/index';
const props = defineProps({
backUrl: {
@@ -24,24 +23,16 @@ const goBack = () => {
}
};
const buttonStyleClass = props.compact
? 'text-sm text-n-slate-11'
: 'text-base text-n-blue-text';
const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
</script>
<template>
<button
class="flex items-center p-0 font-normal cursor-pointer gap-1"
class="flex items-center p-0 font-normal cursor-pointer text-n-slate-11"
:class="buttonStyleClass"
@click.capture="goBack"
>
<Icon
icon="i-lucide-chevron-left"
class="ltr:-ml-1 rtl:-mr-1"
:class="
props.compact ? 'text-n-slate-11 size-4' : 'text-n-blue-text size-5'
"
/>
<fluent-icon icon="chevron-left" class="-ml-1" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>
</template>
@@ -1,61 +0,0 @@
<script>
export default {
props: {
contentAttributes: {
type: Object,
default: () => ({}),
},
content: {
type: String,
default: '',
},
},
emits: ['close'],
computed: {
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
translations() {
return this.contentAttributes.translations || {};
},
},
methods: {
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<woot-modal
modal-type="right-aligned"
class="text-left"
show
:on-close="onClose"
>
<div class="content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="content" class="mb-0" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div v-for="(translation, language) in translations" :key="language">
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
</template>
@@ -5,9 +5,26 @@ import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: key => (key === 'AGENT_MGMT.MULTI_SELECTOR.LIST.NONE' ? 'None' : key),
}),
}));
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/agentHelper');
// Create a mock None agent
const mockNoneAgent = {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
const mockUseMapGetter = (overrides = {}) => {
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
@@ -28,14 +45,6 @@ describe('useAgentsList', () => {
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
formattedAgentsData.slice(1)
);
agentHelper.getCombinedAgents.mockImplementation(
(agents, includeNone, isAgentSelected) => {
if (includeNone && isAgentSelected) {
return [agentHelper.createNoneAgent, ...agents];
}
return agents;
}
);
mockUseMapGetter();
});
@@ -44,24 +53,26 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(agentsList.value).toEqual([
agentHelper.createNoneAgent,
...formattedAgentsData.slice(1),
]);
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('includes None agent when includeNoneAgent is true', () => {
const { agentsList } = useAgentsList(true);
expect(agentsList.value[0]).toEqual(agentHelper.createNoneAgent);
expect(agentsList.value.length).toBe(formattedAgentsData.length);
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('excludes None agent when includeNoneAgent is false', () => {
const { agentsList } = useAgentsList(false);
expect(agentsList.value[0]).not.toEqual(agentHelper.createNoneAgent);
expect(agentsList.value.length).toBe(formattedAgentsData.length - 1);
expect(agentsList.value[0].id).not.toBe(0);
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('handles empty assignable agents', () => {
@@ -73,7 +84,7 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
expect(agentsList.value).toEqual([mockNoneAgent]);
});
it('handles missing inbox_id', () => {
@@ -86,6 +97,6 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
expect(agentsList.value).toEqual([mockNoneAgent]);
});
});
@@ -1,9 +1,9 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
getCombinedAgents,
} from 'dashboard/helper/agentHelper';
/**
@@ -13,6 +13,7 @@ import {
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
@@ -21,6 +22,19 @@ export function useAgentsList(includeNoneAgent = true) {
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
/**
* Creates a 'None' agent object
* @returns {Object} None agent object
*/
const createNoneAgent = () => ({
confirmed: true,
name: t('AGENT_MGMT.MULTI_SELECTOR.LIST.NONE') || 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
@@ -43,11 +57,10 @@ export function useAgentsList(includeNoneAgent = true) {
agentsByUpdatedPresence
);
return getCombinedAgents(
filteredAgentsByAvailability,
includeNoneAgent,
isAgentSelected.value
);
return [
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
...filteredAgentsByAvailability,
];
});
return {
@@ -16,7 +16,6 @@ import {
export default function useAutomationValues() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
@@ -61,6 +60,19 @@ export default function useAutomationValues() {
];
});
/**
* Adds a translated "None" option to the beginning of a list
* @param {Array} list - The list to add "None" to
* @returns {Array} A new array with "None" option at the beginning
*/
const addNoneToList = list => [
{
id: 'nil',
name: t('AUTOMATION.NONE_OPTION') || 'None',
},
...(list || []),
];
/**
* Gets the condition dropdown values for a given type.
* @param {string} type - The type of condition.
@@ -95,6 +107,7 @@ export default function useAutomationValues() {
slaPolicies: slaPolicies.value,
languages,
type,
addNoneToListFn: addNoneToList,
});
};
@@ -17,7 +17,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'user:logout': this.onLogout,
'page:reload': this.onReload,
'assignee.changed': this.onAssigneeChanged,
'team.changed': this.onTeamChanged,
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
'conversation.contact_changed': this.onConversationContactChange,
@@ -77,14 +76,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onTeamChanged = payload => {
const { id } = payload;
if (id) {
this.app.$store.dispatch('updateConversation', payload);
}
this.fetchConversationStats();
};
onConversationCreated = data => {
this.app.$store.dispatch('addConversation', data);
this.fetchConversationStats();
@@ -1,16 +1,3 @@
/**
* Default agent object representing 'None'
* @type {Object}
*/
export const createNoneAgent = {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
/**
* Filters and sorts agents by availability status
* @param {Array} agents - List of agents
@@ -62,22 +49,3 @@ export const getAgentsByUpdatedPresence = (
);
return agentsWithDynamicPresenceUpdate;
};
/**
* Combines the filtered agents with the 'None' agent option if applicable.
*
* @param {Array} filteredAgentsByAvailability - The list of agents sorted by availability.
* @param {boolean} includeNoneAgent - Whether to include the 'None' agent option.
* @param {boolean} isAgentSelected - Whether an agent is currently selected.
* @returns {Array} The combined list of agents, potentially including the 'None' agent.
*/
export const getCombinedAgents = (
filteredAgentsByAvailability,
includeNoneAgent,
isAgentSelected
) => {
return [
...(includeNoneAgent && isAgentSelected ? [createNoneAgent] : []),
...filteredAgentsByAvailability,
];
};
@@ -87,6 +87,7 @@ export const generateCustomAttributeTypes = (customAttributes, type) => {
};
export const generateConditionOptions = (options, key = 'id') => {
if (!options || !Array.isArray(options)) return [];
return options.map(i => {
return {
id: i[key],
@@ -95,25 +96,17 @@ export const generateConditionOptions = (options, key = 'id') => {
});
};
// Add the "None" option to the agent list
export const addNoneToList = agents => [
{
id: 'nil',
name: 'None',
},
...(agents || []),
];
export const getActionOptions = ({
agents,
teams,
labels,
slaPolicies,
type,
addNoneToListFn,
}) => {
const actionsMap = {
assign_agent: addNoneToList(agents),
assign_team: addNoneToList(teams),
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
assign_team: addNoneToListFn ? addNoneToListFn(teams) : teams,
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
@@ -2,8 +2,6 @@ import {
getAgentsByAvailability,
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
getCombinedAgents,
createNoneAgent,
} from '../agentHelper';
import {
allAgentsData,
@@ -93,39 +91,4 @@ describe('agentHelper', () => {
).toEqual([]);
});
});
describe('getCombinedAgents', () => {
it('includes None agent when includeNoneAgent is true and isAgentSelected is true', () => {
const result = getCombinedAgents(sortedByAvailability, true, true);
expect(result).toEqual([createNoneAgent, ...sortedByAvailability]);
expect(result.length).toBe(sortedByAvailability.length + 1);
expect(result[0]).toEqual(createNoneAgent);
});
it('excludes None agent when includeNoneAgent is false', () => {
const result = getCombinedAgents(sortedByAvailability, false, true);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
expect(result[0]).not.toEqual(createNoneAgent);
});
it('excludes None agent when isAgentSelected is false', () => {
const result = getCombinedAgents(sortedByAvailability, true, false);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
expect(result[0]).not.toEqual(createNoneAgent);
});
it('returns only filtered agents when both includeNoneAgent and isAgentSelected are false', () => {
const result = getCombinedAgents(sortedByAvailability, false, false);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
});
it('handles empty filteredAgentsByAvailability array', () => {
const result = getCombinedAgents([], true, true);
expect(result).toEqual([createNoneAgent]);
expect(result.length).toBe(1);
});
});
});
@@ -118,6 +118,46 @@ describe('getActionOptions', () => {
expectedOptions
);
});
it('adds None option when addNoneToListFn is provided', () => {
const mockAddNoneToListFn = list => [
{ id: 'nil', name: 'None' },
...(list || []),
];
const agents = [
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
];
const expectedOptions = [
{ id: 'nil', name: 'None' },
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
];
expect(
helpers.getActionOptions({
agents,
type: 'assign_agent',
addNoneToListFn: mockAddNoneToListFn,
})
).toEqual(expectedOptions);
});
it('does not add None option when addNoneToListFn is not provided', () => {
const agents = [
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
];
expect(
helpers.getActionOptions({
agents,
type: 'assign_agent',
})
).toEqual(agents);
});
});
describe('getConditionOptions', () => {
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "اختر وكيل",
"TEAM": "اختر فريق"
},
"LIST": {
"NONE": "لا شيء"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "لم يتم العثور على وكلاء",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Изберете агент",
"TEAM": "Изберете екип"
},
"LIST": {
"NONE": "Нито един"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Няма намерени агенти",
@@ -3,8 +3,8 @@
"HEADER": "Agents",
"HEADER_BTN_TXT": "Afegir Agent",
"LOADING": "S'està recollint la llista d'agents",
"DESCRIPTION": "An agent is a member of your customer support team who can view and respond to user messages. The list below shows all the agents in your account.",
"LEARN_MORE": "Learn about user roles",
"DESCRIPTION": "",
"LEARN_MORE": "Rakibkazi",
"AGENT_TYPES": {
"ADMINISTRATOR": "Administrador/a",
"AGENT": "Agent"
@@ -105,6 +105,9 @@
"AGENT": "Seleccionar Agent",
"TEAM": "Selecciona equip"
},
"LIST": {
"NONE": "Ningú"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No s'han trobat agents",
@@ -105,6 +105,9 @@
"AGENT": "Vybrat agenta",
"TEAM": "Vybrat tým"
},
"LIST": {
"NONE": "Nic"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nenalezeni žádní agenti",
@@ -105,6 +105,9 @@
"AGENT": "Vælg agent",
"TEAM": "Vælg hold"
},
"LIST": {
"NONE": "Ingen"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Ingen agenter fundet",
@@ -105,6 +105,9 @@
"AGENT": "Agent auswählen",
"TEAM": "Team auswählen"
},
"LIST": {
"NONE": "Keine"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Keine Agenten gefunden",
@@ -105,6 +105,9 @@
"AGENT": "Επιλογή πράκτορα",
"TEAM": "Επιλογή ομάδας"
},
"LIST": {
"NONE": "Κανένα"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Δεν βρέθηκαν Πράκτορες",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -125,6 +125,7 @@
"ACTION_PARAMETERS_REQUIRED": "Action parameters are required",
"ATLEAST_ONE_CONDITION_REQUIRED": "At least one condition is required",
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
}
},
"NONE_OPTION": "None"
}
}
@@ -12,6 +12,8 @@
"NO_INBOX_2": " to get started",
"NO_INBOX_AGENT": "Uh Oh! Looks like you are not part of any inbox. Please contact your administrator",
"SEARCH_MESSAGES": "Search for messages in conversations",
"VIEW_ORIGINAL": "View original",
"VIEW_TRANSLATED": "View translated",
"EMPTY_STATE": {
"CMD_BAR": "to open command menu",
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"
@@ -105,6 +105,9 @@
"AGENT": "Seleccionar agente",
"TEAM": "Seleccionar equipo"
},
"LIST": {
"NONE": "Ninguna"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No se encontraron agentes",
@@ -105,6 +105,9 @@
"AGENT": "انتخاب اپراتور",
"TEAM": "انتخاب تیم"
},
"LIST": {
"NONE": "هیچکدام"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "اپراتوری یافت نشد",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Edustajia ei löytynyt",
@@ -105,6 +105,9 @@
"AGENT": "Sélectionner un agent",
"TEAM": "Sélectionner une équipe"
},
"LIST": {
"NONE": "Aucun"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Aucun agent trouvé",
@@ -105,6 +105,9 @@
"AGENT": "בחר נציג",
"TEAM": "בחר קבוצה"
},
"LIST": {
"NONE": "כלום"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "לא נמצא סוכן",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "Nijedno"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Ügynök kiválasztása",
"TEAM": "Csapat kiválasztása"
},
"LIST": {
"NONE": "Nincs"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nem találunk ügynököt",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Pilih Agen",
"TEAM": "Pilih tim"
},
"LIST": {
"NONE": "Tidak ada"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Tidak ada agen",
@@ -105,6 +105,9 @@
"AGENT": "Velja þjónustufulltrúa",
"TEAM": "Velja teymi"
},
"LIST": {
"NONE": "Enginn"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Engir þjónustufulltrúar fundust",
@@ -105,6 +105,9 @@
"AGENT": "Seleziona un'agente",
"TEAM": "Seleziona team"
},
"LIST": {
"NONE": "Nessuno"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nessun agente trovato",
@@ -105,6 +105,9 @@
"AGENT": "担当者を選択",
"TEAM": "チームを選択"
},
"LIST": {
"NONE": "なし"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "担当者が見つかりません",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "에이전트 선택",
"TEAM": "팀 선택"
},
"LIST": {
"NONE": "없음"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "에이전트를 찾을 수 없음",
@@ -105,6 +105,9 @@
"AGENT": "Pasirinkti agentą",
"TEAM": "Pasirinkite komandą"
},
"LIST": {
"NONE": "Nėra"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Agentų nerasta",
@@ -105,6 +105,9 @@
"AGENT": "Izvēlieties aģentu",
"TEAM": "Izvēlieties komandu"
},
"LIST": {
"NONE": "Nav"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Aģenti nav atrasti",
@@ -105,6 +105,9 @@
"AGENT": "ഏജന്റിനെ തിരഞ്ഞെടുക്കുക",
"TEAM": "ടീം തിരഞ്ഞെടുക്കുക"
},
"LIST": {
"NONE": "ഒന്നുമില്ല"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "ഏജന്റകളെ ഒന്നും കണ്ടെത്താൻ സാധിച്ചില്ല",
@@ -105,6 +105,9 @@
"AGENT": "Pilih ejen",
"TEAM": "Pilih pasukan"
},
"LIST": {
"NONE": "Tiada"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Tiada ejen dijumpa",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Selecteer agent",
"TEAM": "Selecteer team"
},
"LIST": {
"NONE": "Geen"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Geen medewerkers gevonden",
@@ -105,6 +105,9 @@
"AGENT": "Velg agent",
"TEAM": "Velg gruppe"
},
"LIST": {
"NONE": "Ingen"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Ingen agenter funnet",
@@ -105,6 +105,9 @@
"AGENT": "Wybierz konsultanta",
"TEAM": "Wybierz zespół"
},
"LIST": {
"NONE": "Brak"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nie znaleziono agentów",
@@ -105,6 +105,9 @@
"AGENT": "Escolher agente",
"TEAM": "Escolher equipa"
},
"LIST": {
"NONE": "Nenhuma"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nenhum agente encontrado",
@@ -105,6 +105,9 @@
"AGENT": "Selecionar agente",
"TEAM": "Selecionar time"
},
"LIST": {
"NONE": "Nenhuma"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nenhum agente encontrado",
@@ -3,7 +3,7 @@
"HEADER": "Auditoria",
"HEADER_BTN_TXT": "Adicionar Logs de Auditoria",
"LOADING": "Buscando Logs de Auditoria",
"DESCRIPTION": "Logs de Auditoria mantêm um registro de atividades em sua conta, permitindo que você acompanhe e auditoria de sua conta, equipe ou serviços.",
"DESCRIPTION": "Logs de Auditoria mantêm um registro de atividades em sua conta, permitindo que você acompanhe e auditore sua conta, equipe ou serviços.",
"LEARN_MORE": "Saiba mais sobre os logs de auditoria",
"SEARCH_404": "Não existem itens correspondentes a esta consulta",
"SIDEBAR_TXT": "<p><b>Logs de Auditoria</b> </p><p> Os Logs de Auditoria são rastros para eventos e ações em um Sistema Chatwoot. </p>",
@@ -105,6 +105,9 @@
"AGENT": "Selectați agentul",
"TEAM": "Selectați echipa"
},
"LIST": {
"NONE": "Nimic"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Niciun agent găsit",
@@ -105,6 +105,9 @@
"AGENT": "Выбрать Агента",
"TEAM": "Выберите команду"
},
"LIST": {
"NONE": "Ничего"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Операторы не найдены",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Vybrať agenta",
"TEAM": "Vybrať tím"
},
"LIST": {
"NONE": "Žiadne"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Žiadni agenti neboli nájdení",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Izaberite agenta",
"TEAM": "Izaberite tim"
},
"LIST": {
"NONE": "Niko"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nema pronađenih agenata",
@@ -105,6 +105,9 @@
"AGENT": "Välj agent",
"TEAM": "Välj team"
},
"LIST": {
"NONE": "Inget"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Inga agenter hittades",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "เลือกพนักงาน",
"TEAM": "เลือกทีม"
},
"LIST": {
"NONE": "ไม่มี"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "ไม่พบพนักงาน",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Temsilci seçin",
"TEAM": "Takım seçin"
},
"LIST": {
"NONE": "Hiç"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Kullanıcı bulunamadı",
@@ -105,6 +105,9 @@
"AGENT": "Виберіть агента",
"TEAM": "Виберіть команду"
},
"LIST": {
"NONE": "Нiчого"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Агентів не знайдено",
@@ -105,6 +105,9 @@
"AGENT": "ایجنٹ منتخب کریں۔",
"TEAM": "ٹیم منتخب کریں۔"
},
"LIST": {
"NONE": "کوئی نہیں۔"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "کوئی ایجنٹ نہیں ملا",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Chọn thành viên",
"TEAM": "Chọn nhóm"
},
"LIST": {
"NONE": "Không có"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Không tìm thấy nhà cung cấp",
@@ -105,6 +105,9 @@
"AGENT": "选择客服代表",
"TEAM": "选择团队"
},
"LIST": {
"NONE": "啥都没有"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "未找到客服代表",
@@ -105,6 +105,9 @@
"AGENT": "選擇客服",
"TEAM": "選擇團隊"
},
"LIST": {
"NONE": "無"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "查無客服",
@@ -11,14 +11,12 @@ import {
ACCOUNT_EVENTS,
CONVERSATION_EVENTS,
} from '../../../helper/AnalyticsHelper/events';
import TranslateModal from 'dashboard/components/widgets/conversation/bubble/TranslateModal.vue';
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
import { useTrack } from 'dashboard/composables';
export default {
components: {
AddCannedModal,
TranslateModal,
MenuItem,
ContextMenu,
},
@@ -54,7 +52,6 @@ export default {
data() {
return {
isCannedResponseModalOpen: false,
showTranslateModal: false,
showDeleteModal: false,
};
},
@@ -125,15 +122,11 @@ export default {
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
this.handleClose();
this.showTranslateModal = true;
},
handleReplyTo() {
this.$emit('replyTo', this.message);
this.handleClose();
},
onCloseTranslateModal() {
this.showTranslateModal = false;
},
openDeleteModal() {
this.handleClose();
this.showDeleteModal = true;
@@ -170,13 +163,6 @@ export default {
:on-close="hideCannedResponseModal"
/>
</woot-modal>
<!-- Translate Content -->
<TranslateModal
v-if="showTranslateModal"
:content="messageContent"
:content-attributes="contentAttributes"
@close="onCloseTranslateModal"
/>
<!-- Confirm Deletion -->
<woot-delete-modal
v-if="showDeleteModal"
@@ -251,7 +251,7 @@ export default {
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM')
"
:input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.INPUT')
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
"
@select="onClickAssignTeam"
/>
@@ -54,11 +54,9 @@ export default {
<template>
<div
class="flex justify-between items-center h-14 min-h-[3.5rem] px-4 py-2 bg-n-background border-b border-n-weak"
class="flex justify-between items-center h-20 min-h-[3.5rem] px-4 py-2 bg-n-background"
>
<h1
class="flex items-center mb-0 text-2xl text-slate-900 dark:text-slate-100"
>
<h1 class="flex items-center mb-0 text-2xl text-n-slate-12">
<woot-sidemenu-icon v-if="showSidemenuIcon" />
<BackButton
v-if="showBackButton"
@@ -66,21 +64,16 @@ export default {
:back-url="backUrl"
class="ml-2 mr-4"
/>
<fluent-icon
v-if="icon"
:icon="icon"
:class="iconClass"
class="hidden ml-1 mr-2 rtl:ml-2 rtl:mr-1 md:block"
/>
<slot />
<span class="text-2xl font-medium text-slate-900 dark:text-slate-100">
<span class="text-xl font-medium text-slate-900 dark:text-slate-100">
{{ headerTitle }}
</span>
</h1>
<router-link
v-if="showNewButton && isAdmin"
:to="buttonRoute"
class="button success button--fixed-top px-3.5 py-1 rounded-[5px] flex gap-2"
class="button success button--fixed-top rounded-[5px] flex gap-2"
>
<fluent-icon icon="add-circle" />
<span class="button__content">
@@ -17,29 +17,31 @@ const props = defineProps({
const { t } = useI18n();
const showNewButton = computed(
() => props.newButtonRoutes.length !== 0 && !props.showBackButton
() => props.newButtonRoutes.length && !props.showBackButton
);
</script>
<template>
<div
class="flex flex-1 h-full justify-between flex-col m-0 bg-n-background overflow-auto"
>
<SettingsHeader
button-route="new"
:icon="icon"
:header-title="t(headerTitle)"
:button-text="t(headerButtonText)"
:show-back-button="showBackButton"
:back-url="backUrl"
:show-new-button="showNewButton"
:show-sidemenu-icon="showSidemenuIcon"
/>
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" />
</keep-alive>
<component :is="Component" v-else />
</router-view>
<div class="flex flex-1 flex-col m-0 bg-n-background overflow-auto">
<div class="max-w-6xl mx-auto w-full flex flex-col flex-1">
<SettingsHeader
button-route="new"
:icon="icon"
:header-title="t(headerTitle)"
:button-text="t(headerButtonText)"
:show-back-button="showBackButton"
:back-url="backUrl"
:show-new-button="showNewButton"
:show-sidemenu-icon="showSidemenuIcon"
class="sticky top-0 z-20"
/>
<router-view v-slot="{ Component }" class="px-5 flex-1 overflow-hidden">
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
<keep-alive v-else>
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</div>
</div>
</template>
@@ -9,8 +9,14 @@ import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import semver from 'semver';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BaseSettingsHeader,
V4Button,
},
setup() {
const { updateUISettings } = useUISettings();
const { enabledLanguages } = useConfig();
@@ -161,131 +167,130 @@ export default {
</script>
<template>
<div class="flex-grow flex-shrink min-w-0 p-6 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex flex-row p-4 border-b border-slate-25 dark:border-slate-800"
>
<div class="flex flex-col w-full">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')">
<template #actions>
<V4Button blue :loading="isUpdating" @click="updateAccount">
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
</V4Button>
</template>
</BaseSettingsHeader>
<div class="flex-grow flex-shrink min-w-0 overflow-auto mt-3">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
</div>
</div>
</div>
</form>
<div
class="flex flex-row p-4 border-slate-25 dark:border-slate-700 text-black-900 dark:text-slate-300"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<woot-code :script="getAccountId" />
</div>
</div>
<div class="p-4 text-sm text-center">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="build-id">
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
</div>
</div>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<woot-submit-button
class="button nice success button--fixed-top"
:button-text="$t('GENERAL_SETTINGS.SUBMIT')"
:loading="isUpdating"
/>
</form>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
<div class="flex flex-row">
<div class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0">
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<woot-code :script="getAccountId" />
</div>
</div>
<div class="p-4 text-sm text-center">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="build-id">
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
</div>
</div>
</div>
</template>
@@ -1,6 +1,6 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsContent from '../Wrapper.vue';
import Index from './Index.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
export default {
routes: [
@@ -9,12 +9,7 @@ export default {
meta: {
permissions: ['administrator'],
},
component: SettingsContent,
props: {
headerTitle: 'GENERAL_SETTINGS.TITLE',
icon: 'briefcase',
showNewButton: false,
},
component: SettingsWrapper,
children: [
{
path: '',
@@ -41,7 +41,7 @@ const openInNewTab = url => {
</script>
<template>
<div class="flex flex-col items-start w-full gap-2 pt-4">
<div class="flex flex-col items-start w-full gap-2">
<BackButton
v-if="backButtonLabel"
compact
@@ -60,13 +60,11 @@ const openInNewTab = url => {
size="14"
:icon="iconName"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
class="flex-shrink-0 text-n-brand"
/>
</div>
</div>
<h1
class="text-2xl font-semibold font-interDisplay tracking-[0.3px] text-slate-900 dark:text-slate-25"
>
<h1 class="text-xl font-medium tracking-tight text-n-slate-12">
{{ title }}
</h1>
</div>
@@ -75,9 +73,9 @@ const openInNewTab = url => {
<slot name="actions" />
</div>
</div>
<div class="flex flex-col w-full gap-3 text-slate-600 dark:text-slate-300">
<div class="flex flex-col w-full gap-3 text-n-slate-11">
<p
class="mb-0 text-base font-normal line-clamp-5 sm:line-clamp-none max-w-3xl tracking-[-0.1px]"
class="mb-0 text-sm font-normal line-clamp-5 sm:line-clamp-none max-w-3xl"
>
<slot name="description">{{ description }}</slot>
</p>
@@ -87,7 +85,7 @@ const openInNewTab = url => {
:href="helpURL"
target="_blank"
rel="noopener noreferrer"
class="items-center hidden gap-1 text-sm font-medium sm:inline-flex w-fit text-n-brand dark:text-n-brand hover:underline"
class="items-center hidden gap-1 text-sm font-medium sm:inline-flex w-fit text-n-brand hover:underline"
>
{{ linkText }}
<Icon
@@ -13,7 +13,7 @@ defineProps({
<template>
<div
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 w-full"
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between group outline outline-n-container outline-1 bg-n-alpha-3 rounded-2xl shadow w-full"
>
<slot name="leftSection">
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
@@ -21,7 +21,7 @@ defineProps({
class="flex items-center justify-between w-full gap-3 sm:justify-normal whitespace-nowrap"
>
<h3
class="justify-between text-sm font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
class="justify-between tracking-tight font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
>
<slot name="title">
{{ title }}
@@ -40,9 +40,7 @@ export default {
</script>
<template>
<div
class="flex flex-row overflow-auto p-4 h-full bg-n-alpha-2 dark:bg-n-solid-1"
>
<div class="flex flex-row overflow-auto h-full">
<woot-wizard
class="hidden md:block w-1/4"
:global-config="globalConfig"
@@ -360,7 +360,7 @@ export default {
<template>
<div
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto settings bg-n-solid-1"
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto settings"
>
<SettingIntroBanner
:header-image="inbox.avatarUrl"
@@ -1,78 +1,76 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
integrationId: {
type: [String, Number],
required: true,
},
integrationName: { type: String, default: '' },
integrationDescription: { type: String, default: '' },
integrationEnabled: { type: Boolean, default: false },
integrationAction: { type: String, default: '' },
actionButtonText: { type: String, default: '' },
deleteConfirmationText: { type: Object, default: () => ({}) },
},
data() {
return {
showDeleteConfirmationPopup: false,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
globalConfig: 'globalConfig/get',
}),
},
methods: {
frontendURL,
openDeletePopup() {
this.showDeleteConfirmationPopup = true;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.closeDeletePopup();
this.deleteIntegration(this.deleteIntegration);
this.$router.push({ name: 'settings_integrations' });
},
async deleteIntegration() {
try {
await this.$store.dispatch(
'integrations/deleteIntegration',
this.integrationId
);
useAlert(this.$t('INTEGRATION_SETTINGS.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.API.ERROR_MESSAGE')
);
}
},
const props = defineProps({
integrationId: {
type: [String, Number],
required: true,
},
integrationName: { type: String, default: '' },
integrationDescription: { type: String, default: '' },
integrationEnabled: { type: Boolean, default: false },
integrationAction: { type: String, default: '' },
actionButtonText: { type: String, default: '' },
deleteConfirmationText: { type: Object, default: () => ({}) },
});
const store = useStore();
const router = useRouter();
const showDeleteConfirmationPopup = ref(false);
const accountId = computed(() => store.getters.getCurrentAccountId);
const globalConfig = computed(() => store.getters['globalConfig/get']);
const openDeletePopup = () => {
showDeleteConfirmationPopup.value = true;
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
};
const deleteIntegration = async () => {
try {
await store.dispatch('integrations/deleteIntegration', props.integrationId);
useAlert('INTEGRATION_SETTINGS.DELETE.API.SUCCESS_MESSAGE');
} catch (error) {
useAlert('INTEGRATION_SETTINGS.WEBHOOK.DELETE.API.ERROR_MESSAGE');
}
};
const confirmDeletion = () => {
closeDeletePopup();
deleteIntegration();
router.push({ name: 'settings_integrations' });
};
</script>
<template>
<div
class="flex flex-col items-start justify-between md:flex-row md:items-center"
class="flex flex-col items-start justify-between md:flex-row md:items-center p-4 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
>
<div class="flex items-center justify-start flex-1 m-0 mx-4">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="w-16 h-16 p-2 mr-4"
/>
<div class="flex items-center justify-start flex-1 m-0 mx-4 gap-6">
<div class="flex h-16 w-16 items-center justify-center">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>
<div>
<h3 class="mb-1 text-xl font-medium text-slate-800 dark:text-slate-100">
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
{{ integrationName }}
</h3>
<p class="text-slate-700 dark:text-slate-200">
<p class="text-n-slate-11 text-sm leading-6">
{{
useInstallationName(
integrationDescription,
@@ -108,7 +108,9 @@ export default {
</script>
<template>
<div class="overflow-auto p-4 max-w-full my-auto flex flex-wrap h-full">
<div
class="overflow-auto p-4 max-w-6xl mx-auto my-auto flex flex-wrap h-full"
>
<woot-button
v-if="showAddButton"
color-scheme="success"
@@ -37,7 +37,7 @@ const integrationStatus = computed(() =>
);
const integrationStatusColor = computed(() =>
props.enabled ? 'bg-green-500' : 'bg-slate-200'
props.enabled ? 'bg-n-teal-9' : 'bg-n-slate-8'
);
const actionURL = computed(() =>
@@ -47,30 +47,30 @@ const actionURL = computed(() =>
<template>
<div
class="flex flex-col flex-1 p-6 bg-white border border-solid rounded-md dark:bg-slate-800 border-slate-50 dark:border-slate-700/50"
class="flex flex-col flex-1 p-6 m-[1px] outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
>
<div class="flex items-start justify-between">
<div class="flex h-12 w-12 mb-4">
<img
:src="`/dashboard/images/integrations/${id}.png`"
class="max-w-full rounded-md border border-slate-50 dark:border-slate-700/50 shadow-sm block dark:hidden bg-white dark:bg-slate-900"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${id}-dark.png`"
class="max-w-full rounded-md border border-slate-50 dark:border-slate-700/50 shadow-sm hidden dark:block bg-white dark:bg-slate-900"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>
<fluent-icon
<span
v-tooltip="integrationStatus"
size="20"
class="text-white p-0.5 rounded-full"
class="text-white p-0.5 rounded-full w-5 h-5 flex items-center justify-center"
:class="integrationStatusColor"
icon="checkmark"
/>
>
<i class="i-ph-check-bold text-sm" />
</span>
</div>
<div class="flex flex-col m-0 flex-1">
<div
class="font-medium mb-2 text-slate-800 dark:text-slate-100 flex justify-between items-center"
class="font-medium mb-2 text-n-slate-12 flex justify-between items-center"
>
<span class="text-base font-semibold">{{ name }}</span>
<router-link :to="actionURL">
@@ -79,7 +79,7 @@ const actionURL = computed(() =>
</woot-button>
</router-link>
</div>
<p class="text-slate-700 dark:text-slate-200">
<p class="text-n-slate-11">
{{ useInstallationName(description, globalConfig.installationName) }}
</p>
</div>
@@ -35,32 +35,23 @@ onMounted(() => {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto">
<div class="flex flex-col">
<div class="flex flex-col">
<div>
<div
v-if="integrationLoaded && !uiFlags.isCreatingLinear"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</div>
<div class="flex-grow flex-shrink p-4 overflow-auto max-w-6xl mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingLinear">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
@@ -47,31 +47,19 @@ export default {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto">
<div class="flex flex-col">
<div class="flex flex-col">
<div>
<div
v-if="integrationLoaded"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction()"
/>
</div>
<div
v-if="integration.enabled"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<IntegrationHelpText />
</div>
</div>
</div>
<div class="max-w-6xl">
<div v-if="integrationLoaded">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction()"
/>
</div>
<div v-if="integration.enabled">
<IntegrationHelpText />
</div>
</div>
</template>
@@ -1,63 +1,62 @@
<script>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
export default {
props: {
integrationId: {
type: String,
required: true,
},
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
integrationId: {
type: String,
required: true,
},
emits: ['add', 'delete'],
setup(props) {
const { integration, hasConnectedHooks } = useIntegrationHook(
props.integrationId
);
return { integration, hasConnectedHooks };
},
};
});
defineEmits(['add', 'delete']);
const { integration, hasConnectedHooks } = useIntegrationHook(
props.integrationId
);
</script>
<template>
<div class="flex-shrink flex-grow overflow-auto p-4">
<div class="flex flex-col">
<div
class="bg-white dark:bg-slate-800 border border-solid border-slate-75 dark:border-slate-700/50 rounded-xl mb-4 p-4"
>
<div class="flex">
<div class="flex h-[6.25rem] w-[6.25rem]">
<img
:src="`/dashboard/images/integrations/${integration.id}.png`"
class="max-w-full rounded-md border border-slate-50 dark:border-slate-700/50 shadow-sm block dark:hidden bg-white dark:bg-slate-900"
/>
<img
:src="`/dashboard/images/integrations/${integration.id}-dark.png`"
class="max-w-full rounded-md border border-slate-50 dark:border-slate-700/50 shadow-sm hidden dark:block bg-white dark:bg-slate-900"
<div
class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow flex-grow overflow-auto p-4"
>
<div class="flex items-center justify-center">
<div class="flex h-16 w-16 items-center justify-center">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
{{ integration.name }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
{{ integration.description }}
</p>
</div>
<div class="flex justify-center items-center mb-0 w-[15%]">
<div v-if="hasConnectedHooks">
<div @click="$emit('delete', integration.hooks[0])">
<Button
ruby
faded
:label="$t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT')"
/>
</div>
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
<h3
class="text-xl font-medium mb-1 text-slate-800 dark:text-slate-100"
>
{{ integration.name }}
</h3>
<p class="text-slate-700 dark:text-slate-200">
{{ integration.description }}
</p>
</div>
<div class="flex justify-center items-center mb-0 w-[15%]">
<div v-if="hasConnectedHooks">
<div @click="$emit('delete', integration.hooks[0])">
<woot-button class="nice alert">
{{ $t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT') }}
</woot-button>
</div>
</div>
<div v-else>
<woot-button class="button nice" @click="$emit('add')">
{{ $t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT') }}
</woot-button>
</div>
</div>
</div>
<div v-else>
<Button
blue
faded
:label="$t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT')"
@click="$emit('add')"
/>
</div>
</div>
</div>
@@ -1,102 +1,99 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Integration from './Integration.vue';
import SelectChannelWarning from './Slack/SelectChannelWarning.vue';
import SlackIntegrationHelpText from './Slack/SlackIntegrationHelpText.vue';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
Integration,
SelectChannelWarning,
SlackIntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
code: { type: String, default: '' },
},
data() {
return { integrationLoaded: false };
},
computed: {
integration() {
return this.$store.getters['integrations/getIntegration']('slack');
},
areHooksAvailable() {
const { hooks = [] } = this.integration || {};
return !!hooks.length;
},
hook() {
const { hooks = [] } = this.integration || {};
const [hook] = hooks;
return hook || {};
},
isIntegrationHookEnabled() {
return this.hook.status || false;
},
hasConnectedAChannel() {
return !!this.hook.reference_id;
},
selectedChannelName() {
if (this.hook.status) {
const { settings: { channel_name: channelName = '' } = {} } = this.hook;
return channelName || 'customer-conversations';
}
return this.$t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.SELECTED');
},
...mapGetters({
uiFlags: 'integrations/getUIFlags',
}),
integrationAction() {
if (this.integration.enabled) {
return 'disconnect';
}
return this.integration.action;
},
},
mounted() {
this.intializeSlackIntegration();
},
methods: {
async intializeSlackIntegration() {
await this.$store.dispatch('integrations/get', 'slack');
if (this.code) {
await this.$store.dispatch('integrations/connectSlack', this.code);
// Clear the query param `code` from the URL as the
// subsequent reloads would result in an error
this.$router.replace(this.$route.path);
}
this.integrationLoaded = true;
},
},
const props = defineProps({
code: { type: String, default: '' },
});
const store = useStore();
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const integrationLoaded = ref(false);
const integration = computed(() => {
return store.getters['integrations/getIntegration']('slack');
});
const areHooksAvailable = computed(() => {
const { hooks = [] } = integration.value || {};
return !!hooks.length;
});
const hook = computed(() => {
const { hooks = [] } = integration.value || {};
const [firstHook] = hooks;
return firstHook || {};
});
const isIntegrationHookEnabled = computed(() => {
return hook.value.status || false;
});
const hasConnectedAChannel = computed(() => {
return !!hook.value.reference_id;
});
const selectedChannelName = computed(() => {
if (hook.value.status) {
const { settings: { channel_name: channelName = '' } = {} } = hook.value;
return channelName || 'customer-conversations';
}
return t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.SELECTED');
});
const uiFlags = computed(() => store.getters['integrations/getUIFlags']);
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return integration.value.action;
});
const intializeSlackIntegration = async () => {
await store.dispatch('integrations/get', 'slack');
if (props.code) {
await store.dispatch('integrations/connectSlack', props.code);
// Clear the query param `code` from the URL as the
// subsequent reloads would result in an error
router.replace(route.path);
}
integrationLoaded.value = true;
};
onMounted(() => {
intializeSlackIntegration();
});
</script>
<template>
<div
v-if="integrationLoaded && !uiFlags.isCreatingSlack"
class="flex flex-col flex-1 overflow-auto"
class="flex flex-col flex-1 overflow-auto gap-5 pt-1 pb-10"
>
<div
class="p-4 bg-white border-b border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:action-button-text="$t('INTEGRATION_SETTINGS.SLACK.DELETE')"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.TITLE'),
message: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.MESSAGE'),
}"
/>
</div>
<div v-if="areHooksAvailable" class="flex-1 p-6">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:action-button-text="$t('INTEGRATION_SETTINGS.SLACK.DELETE')"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.TITLE'),
message: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.MESSAGE'),
}"
/>
<div v-if="areHooksAvailable" class="flex-1">
<SelectChannelWarning
v-if="!isIntegrationHookEnabled"
:has-connected-a-channel="hasConnectedAChannel"
@@ -1,109 +1,99 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
export default {
mixins: [globalConfigMixin],
props: {
hasConnectedAChannel: {
type: Boolean,
default: true,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return { selectedChannelId: '', availableChannels: [] };
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
uiFlags: 'integrations/getUIFlags',
}),
errorDescription() {
return !this.hasConnectedAChannel
? this.$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
: this.$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
},
},
methods: {
async fetchChannels() {
try {
this.availableChannels = await this.$store.dispatch(
'integrations/listAllSlackChannels'
);
this.availableChannels.sort((c1, c2) => c1.name - c2.name);
} catch {
this.$t('INTEGRATION_SETTINGS.SLACK.FAILED_TO_FETCH_CHANNELS');
this.availableChannels = [];
}
},
async updateIntegration() {
try {
await this.$store.dispatch('integrations/updateSlack', {
referenceId: this.selectedChannelId,
});
useAlert(this.$t('INTEGRATION_SETTINGS.SLACK.UPDATE_SUCCESS'));
} catch (error) {
useAlert(error.message || 'INTEGRATION_SETTINGS.SLACK.UPDATE_ERROR');
}
},
const props = defineProps({
hasConnectedAChannel: {
type: Boolean,
default: true,
},
});
const store = useStore();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const selectedChannelId = ref('');
const availableChannels = ref([]);
const uiFlags = computed(() => store.getters['integrations/getUIFlags']);
const errorDescription = computed(() => {
return !props.hasConnectedAChannel
? t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
: t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
});
const globalConfig = computed(() => store.getters['globalConfig/get']);
const formattedErrorMessage = computed(() => {
return formatMessage(
useInstallationName(
errorDescription.value,
globalConfig.value.installationName
),
false
);
});
const fetchChannels = async () => {
try {
availableChannels.value = await store.dispatch(
'integrations/listAllSlackChannels'
);
availableChannels.value.sort((c1, c2) => c1.name - c2.name);
} catch {
t('INTEGRATION_SETTINGS.SLACK.FAILED_TO_FETCH_CHANNELS');
availableChannels.value = [];
}
};
const updateIntegration = async () => {
try {
await store.dispatch('integrations/updateSlack', {
referenceId: selectedChannelId.value,
});
useAlert(t('INTEGRATION_SETTINGS.SLACK.UPDATE_SUCCESS'));
} catch (error) {
useAlert(error.message || 'INTEGRATION_SETTINGS.SLACK.UPDATE_ERROR');
}
};
</script>
<template>
<div
class="px-6 py-4 mb-4 border border-yellow-200 rounded-md bg-yellow-50 dark:border-slate-700 dark:bg-slate-800"
class="px-6 py-4 mb-4 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
>
<div class="flex">
<div class="flex-shrink-0 mt-0.5">
<fluent-icon
icon="alert"
class="text-yellow-500 dark:text-yellow-400"
size="24"
/>
<div class="flex-shrink-0">
<div class="i-lucide-bell text-xl text-n-amber-11 mt-1" />
</div>
<div class="ml-3">
<p
class="mb-1 text-base font-semibold text-yellow-900 dark:text-yellow-500"
>
<p class="mb-1 text-base font-semibold text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.ATTENTION_REQUIRED')
}}
</p>
<div class="mt-2 text-sm text-yellow-800 dark:text-yellow-600">
<p
v-dompurify-html="
formatMessage(
useInstallationName(
errorDescription,
globalConfig.installationName
),
false
)
"
/>
<div class="mt-2 text-sm text-n-slate-11 mb-3">
<p v-dompurify-html="formattedErrorMessage" />
</div>
</div>
</div>
<div v-if="!hasConnectedAChannel" class="mt-2 ml-8">
<woot-submit-button
<div v-if="!hasConnectedAChannel" class="mb-2 mt-1 ml-8">
<Button
v-if="!availableChannels.length"
button-class="smooth small warning"
:loading="uiFlags.isFetchingSlackChannels"
:button-text="
$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.BUTTON_TEXT')
"
spinner-class="warning"
amber
sm
:is-loading="uiFlags.isFetchingSlackChannels"
@click="fetchChannels"
/>
>
{{ $t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.BUTTON_TEXT') }}
</Button>
<div v-else class="inline-flex">
<select
v-model="selectedChannelId"
@@ -120,13 +110,14 @@ export default {
#{{ channel.name }}
</option>
</select>
<woot-submit-button
button-class="smooth small success"
:button-text="$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.UPDATE')"
spinner-class="success"
:loading="uiFlags.isUpdatingSlack"
<Button
teal
sm
:is-loading="uiFlags.isUpdatingSlack"
@click="updateIntegration"
/>
>
{{ $t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.UPDATE') }}
</Button>
</div>
</div>
</div>
@@ -1,41 +1,37 @@
<script>
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
export default {
props: {
selectedChannelName: {
type: String,
required: true,
},
const props = defineProps({
selectedChannelName: {
type: String,
required: true,
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
};
});
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const formattedHelpText = computed(() => {
return formatMessage(
t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.BODY', {
selectedChannelName: props.selectedChannelName,
}),
false
);
});
</script>
<template>
<div
class="flex-1 w-full p-6 bg-white rounded-md border border-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
class="flex-1 w-full px-6 py-5 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
>
<div class="prose-lg max-w-5xl">
<h5 class="dark:text-slate-100">
{{ $t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.TITLE') }}
<h5 class="text-n-slate-12 tracking-tight">
{{ t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.TITLE') }}
</h5>
<p>
<span
v-dompurify-html="
formatMessage(
$t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.BODY', {
selectedChannelName: selectedChannelName,
}),
false
)
"
/>
</p>
<div v-dompurify-html="formattedHelpText" class="text-n-slate-11" />
</div>
</div>
</template>
@@ -29,9 +29,7 @@ export default {
</script>
<template>
<div
class="p-3 bg-white dark:bg-slate-900 h-[calc(100vh-3.5rem)] flex flex-col border-l border-n-weak"
>
<div class="p-3 bg-n-background h-full flex flex-col">
<div>
<woot-input
:model-value="macroName"
@@ -24,7 +24,7 @@ export default {
<template>
<div
class="overflow-auto p-4 max-w-full my-auto flex flex-row flex-nowrap h-full bg-slate-25 dark:bg-slate-800"
class="overflow-auto p-4 max-w-full my-auto flex flex-row flex-nowrap h-full"
>
<woot-wizard class="hidden md:block w-1/4" :items="items" />
<router-view />
@@ -28,7 +28,7 @@ export default {
<template>
<div
class="overflow-auto p-4 max-w-full my-auto flex flex-row flex-nowrap h-full bg-slate-25 dark:bg-slate-800"
class="overflow-auto p-4 max-w-full my-auto flex flex-row flex-nowrap h-full"
>
<woot-wizard class="hidden md:block w-1/4" :items="items" />
<router-view />
@@ -6,27 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
const makeCompactJson = obj => {
// get rid of irrelevant data
const {
messages,
additional_attributes,
sla_events,
contact_inbox,
...remaining
} = obj;
const strinRep = JSON.stringify(remaining);
// replace active storage URLs with a placeholder
const activeStorageUrlRegex =
/https?:\/\/[^/]+\/rails\/active_storage[^\s]+/g;
const replacedStr = strinRep.replace(
activeStorageUrlRegex,
'[ACTIVE_STORAGE_URL]'
);
return replacedStr;
};
import * as Sentry from '@sentry/vue';
const state = {
allConversations: [],
@@ -237,31 +217,19 @@ export const mutations = {
// ignore out of order events
if (conversation.updated_at < selectedConversation.updated_at) {
if (window.newrelic) {
window.newrelic.log(
`conversation update mismatch for ${conversation.id} with incoming ${makeCompactJson(conversation)} and stored ${makeCompactJson(selectedConversation)}`,
{ level: 'DEBUG' }
);
}
Sentry.withScope(scope => {
scope.setContext('incoming', conversation);
scope.setContext('stored', selectedConversation);
scope.setContext('incoming_meta', conversation.meta);
scope.setContext('stored_meta', selectedConversation.meta);
Sentry.captureMessage('Conversation update mismatch');
});
return;
}
// in case the timestamp is the same, we track it and let it update
if (conversation.updated_at === selectedConversation.updated_at) {
if (window.newrelic) {
window.newrelic.log(
`conversation update overlap for ${conversation.id} with incoming ${makeCompactJson(conversation)} and stored ${makeCompactJson(selectedConversation)}`,
{ level: 'DEBUG' }
);
}
}
if (window.newrelic) {
window.newrelic.log(
`processing conversation update for ${conversation.id} with incoming ${makeCompactJson(conversation)} and stored ${makeCompactJson(selectedConversation)}`,
{ level: 'DEBUG' }
);
return;
}
const { messages, ...updates } = conversation;
@@ -569,4 +569,382 @@ describe('#mutations', () => {
expect(state.copilotAssistant).toEqual(data.assistant);
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on selected chat', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }],
selectedChatId: 1,
};
mutations[types.SET_ALL_MESSAGES_LOADED](state);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
});
});
describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to false on selected chat', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: true }],
selectedChatId: 1,
};
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
expect(state.allConversations[0].allMessagesLoaded).toBe(false);
});
});
describe('#SET_PREVIOUS_CONVERSATIONS', () => {
it('should prepend messages to conversation messages array', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'msg2' }] }],
};
const payload = { id: 1, data: [{ id: 'msg1' }] };
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([
{ id: 'msg1' },
{ id: 'msg2' },
]);
});
it('should not modify messages if data is empty', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'msg2' }] }],
};
const payload = { id: 1, data: [] };
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg2' }]);
});
});
describe('#SET_MISSING_MESSAGES', () => {
it('should replace message array with new data', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'old' }] }],
};
const payload = { id: 1, data: [{ id: 'new' }] };
mutations[types.SET_MISSING_MESSAGES](state, payload);
expect(state.allConversations[0].messages).toEqual([{ id: 'new' }]);
});
it('should do nothing if conversation is not found', () => {
const state = {
allConversations: [],
};
const payload = { id: 1, data: [{ id: 'new' }] };
mutations[types.SET_MISSING_MESSAGES](state, payload);
expect(state.allConversations).toEqual([]);
});
});
describe('#ASSIGN_AGENT', () => {
it('should assign agent to selected conversation', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [{ id: 1, meta: {} }],
selectedChatId: 1,
};
mutations[types.ASSIGN_AGENT](state, assignee);
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
});
});
describe('#ASSIGN_PRIORITY', () => {
it('should assign priority to conversation', () => {
const priority = { title: 'Urgent', value: 'urgent' };
const state = {
allConversations: [{ id: 1 }],
};
mutations[types.ASSIGN_PRIORITY](state, {
priority,
conversationId: 1,
});
expect(state.allConversations[0].priority).toEqual(priority);
});
});
describe('#MUTE_CONVERSATION', () => {
it('should mute selected conversation', () => {
const state = {
allConversations: [{ id: 1, muted: false }],
selectedChatId: 1,
};
mutations[types.MUTE_CONVERSATION](state);
expect(state.allConversations[0].muted).toBe(true);
});
});
describe('#UNMUTE_CONVERSATION', () => {
it('should unmute selected conversation', () => {
const state = {
allConversations: [{ id: 1, muted: true }],
selectedChatId: 1,
};
mutations[types.UNMUTE_CONVERSATION](state);
expect(state.allConversations[0].muted).toBe(false);
});
});
describe('#UPDATE_CONVERSATION', () => {
it('should update existing conversation', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
messages: [{ id: 'msg1' }],
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg2' }],
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0]).toEqual({
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg1' }],
});
});
it('should add conversation if not found', () => {
const state = {
allConversations: [],
};
const conversation = {
id: 1,
status: 'open',
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
it('should emit events if updating selected conversation', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
},
],
selectedChatId: 1,
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(emitter.emit).toHaveBeenCalledWith('FETCH_LABEL_SUGGESTIONS');
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
});
it('should ignore updates with older timestamps', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 200,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 100,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('open');
});
it('should ignore updates with same timestamps', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 100,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('open');
});
});
describe('#UPDATE_CONVERSATION_CONTACT', () => {
it('should update conversation contact data', () => {
const state = {
allConversations: [
{ id: 1, meta: { sender: { id: 1, name: 'Old Name' } } },
],
};
const payload = {
conversationId: 1,
id: 1,
name: 'New Name',
};
mutations[types.UPDATE_CONVERSATION_CONTACT](state, payload);
// The mutation extracts all properties except conversationId
const { conversationId, ...contact } = payload;
expect(state.allConversations[0].meta.sender).toEqual(contact);
});
it('should do nothing if conversation is not found', () => {
const state = {
allConversations: [],
};
const payload = {
conversationId: 1,
id: 1,
name: 'New Name',
};
mutations[types.UPDATE_CONVERSATION_CONTACT](state, payload);
expect(state.allConversations).toEqual([]);
});
});
describe('#SET_ACTIVE_INBOX', () => {
it('should set current inbox as integer', () => {
const state = {
currentInbox: null,
};
mutations[types.SET_ACTIVE_INBOX](state, '1');
expect(state.currentInbox).toBe(1);
});
it('should set null if no inbox ID provided', () => {
const state = {
currentInbox: 1,
};
mutations[types.SET_ACTIVE_INBOX](state, null);
expect(state.currentInbox).toBe(null);
});
});
describe('#CLEAR_CONTACT_CONVERSATIONS', () => {
it('should remove all conversations with matching contact ID', () => {
const state = {
allConversations: [
{ id: 1, meta: { sender: { id: 1 } } },
{ id: 2, meta: { sender: { id: 2 } } },
{ id: 3, meta: { sender: { id: 1 } } },
],
};
mutations[types.CLEAR_CONTACT_CONVERSATIONS](state, 1);
expect(state.allConversations).toHaveLength(1);
expect(state.allConversations[0].id).toBe(2);
});
});
describe('#ADD_CONVERSATION', () => {
it('should add a new conversation', () => {
const state = {
allConversations: [],
};
const conversation = { id: 1, messages: [] };
mutations[types.ADD_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
});
describe('#SET_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to true', () => {
const state = {
listLoadingStatus: false,
};
mutations[types.SET_LIST_LOADING_STATUS](state);
expect(state.listLoadingStatus).toBe(true);
});
});
describe('#CLEAR_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to false', () => {
const state = {
listLoadingStatus: true,
};
mutations[types.CLEAR_LIST_LOADING_STATUS](state);
expect(state.listLoadingStatus).toBe(false);
});
});
describe('#CHANGE_CHAT_STATUS_FILTER', () => {
it('should update chat status filter', () => {
const state = {
chatStatusFilter: 'open',
};
mutations[types.CHANGE_CHAT_STATUS_FILTER](state, 'resolved');
expect(state.chatStatusFilter).toBe('resolved');
});
});
describe('#UPDATE_ASSIGNEE', () => {
it('should update assignee on conversation', () => {
const state = {
allConversations: [{ id: 1, meta: { assignee: null } }],
};
const payload = {
id: 1,
assignee: { id: 1, name: 'Agent' },
};
mutations[types.UPDATE_ASSIGNEE](state, payload);
expect(state.allConversations[0].meta.assignee).toEqual(payload.assignee);
});
});
describe('#SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION', () => {
it('should update the sync conversation message ID', () => {
const state = {
syncConversationsMessages: {},
};
mutations[types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION](state, {
conversationId: 1,
messageId: 100,
});
expect(state.syncConversationsMessages[1]).toBe(100);
});
});
});
@@ -80,25 +80,8 @@ class BaseActionCableConnector {
}
onReceived = ({ event, data } = {}) => {
const loggableEvents = [
'conversation.created',
'conversation.status_changed',
'team.changed',
'assignee.changed',
'conversation.contact_changed',
'conversation.mentioned',
'conversation.read',
'conversation.updated',
];
if (this.isAValidEvent(data)) {
if (this.events[event] && typeof this.events[event] === 'function') {
if (window.newrelic && loggableEvents.includes(event)) {
window.newrelic.log(
`processing actioncable ${event} with ${JSON.stringify(data, null, 2)} at ${Number(Date.now())}`,
{ level: 'DEBUG' }
);
}
this.events[event](data);
}
}
-1
View File
@@ -3,7 +3,6 @@ class ActionCableBroadcastJob < ApplicationJob
include Events::Types
CONVERSATION_UPDATE_EVENTS = [
CONVERSATION_CREATED,
CONVERSATION_READ,
CONVERSATION_UPDATED,
TEAM_CHANGED,

Some files were not shown because too many files have changed in this diff Show More