Compare commits

..
Author SHA1 Message Date
Pranav 0003ded9fe fix spec 2024-12-09 15:42:37 -08:00
Pranav 087f74bb11 Merge branch 'develop' into feat/captain-inbox-selector 2024-12-09 15:20:21 -08:00
472f6d9345 feat: Ability to lock the conversation to a single thread in API channels (#10329)
Added the possibility to mark as a single conversation in the API type
inbox. This allows the conversation builder to search for the last
conversation.
I thought about searching for the last conversation with created_at:
desc order, as is done in some channels... but I didn't change the way
the conversation is searched.

Fixes: #7726 

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-09 23:36:17 +08:00
Shivam MishraandGitHub 2ce7c8b845 fix: sidebar collapsed on reload (#10561)
When reloading a page, the sidebar item that is actively selected is
collapsed by default. This PR fixes it by expanding it on reload
2024-12-09 15:59:26 +05:30
d902bb1d6f fix: Remove duplicate contactable inbox in the conversation form (#10554)
---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-06 12:31:01 -08:00
Sivin VargheseandGitHub 1b430ffae2 fix: New compose conversation form (#10548) 2024-12-06 15:40:06 +05:30
Sivin VargheseandGitHub afb3e3e649 fix: Fix issues with contact routes in old navigation sidebar (#10547) 2024-12-05 22:46:57 -08:00
Shivam MishraandGitHub 3fd585f40b feat: Throttle meta request for large chat size (#10518)
For large accounts with huge volumes of messages, it can be very
wasteful to make the meta request so often. It also puts un-necessary
load on the DB bombarding it with so many requests. This PR fixes it by
throttling the requests to 5 seconds for all users with more than 1000
accessible chats.

### Why not cache this value in the backend?

Well, it's a bit tricky, since a user can have different permissions
over inboxes and can see different chats, maintaining a cache for each
of them is not effective, besides the requests will reach the server
anyway.
2024-12-05 22:35:30 -08:00
b116ab5ad3 feat(v4): Compose new conversation without multiple clicks (#10545)
---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-05 20:16:29 -08:00
67c90231b6 feat: Add compose conversation components (#10457)
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-05 16:31:47 -08:00
Sivin VargheseandGitHub 41106bccb7 chore: Fix issues with Contact pages (#10544) 2024-12-05 14:24:10 -08:00
cdff624a0a feat: update notification settings (#10529)
https://github.com/user-attachments/assets/52ecf3f8-0329-4268-906e-d6102338f4af

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-05 19:05:04 +05:30
Sivin VargheseandGitHub d635be4b2a fix: Fetch assignable agents in expanded layout (#10540) 2024-12-04 21:26:00 -08:00
Sivin VargheseandGitHub f7b0d5dbe2 fix: Reset contact attributes when form is updated (#10539) 2024-12-04 21:25:28 -08:00
3edc0542cc fix: Fix issue with profile picture not updating (#10532)
This PR resolves the issue with updating the profile picture in the profile settings.

**Cause of issue**
The issue can be reproduced with the old `ProfileAvatar.vue` component.
While the exact reason is unclear, it seems related to cases where the
file might be `null`.

**Solution**
Replaced the old `ProfileAvatar.vue` with `Avatar.vue` and tested it. It
works fine. I’ve attached a loom video below.

Fixes https://linear.app/chatwoot/issue/CW-3768/profile-picture-bug

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-04 15:02:29 -08:00
bf58a18af4 fix: Update contact details page errors (#10536)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-04 13:58:53 -08:00
Shivam MishraandGitHub 9b6830a610 fix: Update UI issues with sidebar (#10535)
This PR fixes a few UI issues with the sidebar

1. `z-index` issues with sidebar dropdowns
2. Move the event listener to the root of the dropdown container, it
allows more consistent behaviour of the trigger, earlier the click on
the trigger when the dropdown was open would cause the container to
re-render
3. Use `perserve-open` for the status switcher menu item in the profile
menu.
4. Use `sessionStorage` instead of `localStorage` to preserve sidebar
dropdown info. When opening the dashboard without directly going to a
specific route, any previous known item would get expanded even if it's
link was not active, this caused issues across tabs too, this fixes it.
5. Use `snakeCaseKeys` instead of `decamelize` we had two packages doing
the same thing
6. Update `vueuse` the new version is vue3 only
2024-12-04 13:48:12 -08:00
769b7171f4 feat(v4): Add new contact details screen (#10504)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-12-03 21:29:47 -08:00
Vishnu NarayananandGitHub d4b6f710bd chore(heroku): Switch heroku stack to the latest stack heroku-24 (#10085)
# Pull Request Template

## Description


- Heroku has marked `heroku-20` stack as EOL by April 2025
- https://help.heroku.com/NPN275RK/heroku-20-end-of-life-faq 
- https://devcenter.heroku.com/articles/heroku-24-stack

Fixes https://linear.app/chatwoot/issue/CW-3552/upgrade-heroku-stack-to-heroku-24
2024-12-02 18:53:59 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Pranav
50e7ceb19b chore(deps): bump rails-html-sanitizer from 1.6.0 to 1.6.1 (#10528)
Bumps
[rails-html-sanitizer](https://github.com/rails/rails-html-sanitizer)
from 1.6.0 to 1.6.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rails/rails-html-sanitizer/releases">rails-html-sanitizer's
releases</a>.</em></p>
<blockquote>
<h2>1.6.1 / 2024-12-02</h2>
<p>This is a performance and security release which addresses several
possible XSS vulnerabilities.</p>
<ul>
<li>
<p>The dependency on Nokogiri is updated to v1.15.7 or &gt;=1.16.8.</p>
<p>This change addresses CVE-2024-53985 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-w8gc-x259-rc7x">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-w8gc-x259-rc7x</a>).</p>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>Disallowed tags will be pruned when they appear in foreign content
(i.e. SVG or MathML content),
regardless of the <code>prune:</code> option value. Previously,
disallowed tags were &quot;stripped&quot; unless the
gem was configured with the <code>prune: true</code> option.</p>
<p>The CVEs addressed by this change are:</p>
<ul>
<li>CVE-2024-53986 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-638j-pmjw-jq48">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-638j-pmjw-jq48</a>)</li>
<li>CVE-2024-53987 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-2x5m-9ch4-qgrr">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-2x5m-9ch4-qgrr</a>)</li>
</ul>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>The tags &quot;noscript&quot;, &quot;mglyph&quot;, and
&quot;malignmark&quot; will not be allowed, even if explicitly added to
the allowlist. If applications try to allow any of these tags, a warning
is emitted and the tags
are removed from the allow-list.</p>
<p>The CVEs addressed by this change are:</p>
<ul>
<li>CVE-2024-53988 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-cfjx-w229-hgx5">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-cfjx-w229-hgx5</a>)</li>
<li>CVE-2024-53989 (<a
href="https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-rxv5-gxqc-xx8g">https://github.com/rails/rails-html-sanitizer/security/advisories/GHSA-rxv5-gxqc-xx8g</a>)</li>
</ul>
<p>Please note that we <em>may</em> restore support for allowing
&quot;noscript&quot; in a future release. We do not
expect to ever allow &quot;mglyph&quot; or &quot;malignmark&quot;,
though, especially since browser support is minimal
for these tags.</p>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>Improve performance by eliminating needless operations on attributes
that are being removed. <a
href="https://redirect.github.com/rails/rails-html-sanitizer/issues/188">#188</a></p>
<p><em>Mike Dalessio</em></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rails/rails-html-sanitizer/blob/main/CHANGELOG.md">rails-html-sanitizer's
changelog</a>.</em></p>
<blockquote>
<h2>1.6.1 / 2024-12-02</h2>
<p>This is a performance and security release which addresses several
possible XSS vulnerabilities.</p>
<ul>
<li>
<p>The dependency on Nokogiri is updated to v1.15.7 or &gt;=1.16.8.</p>
<p>This change addresses CVE-2024-53985 (GHSA-w8gc-x259-rc7x).</p>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>Disallowed tags will be pruned when they appear in foreign content
(i.e. SVG or MathML content),
regardless of the <code>prune:</code> option value. Previously,
disallowed tags were &quot;stripped&quot; unless the
gem was configured with the <code>prune: true</code> option.</p>
<p>The CVEs addressed by this change are:</p>
<ul>
<li>CVE-2024-53986 (GHSA-638j-pmjw-jq48)</li>
<li>CVE-2024-53987 (GHSA-2x5m-9ch4-qgrr)</li>
</ul>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>The tags &quot;noscript&quot;, &quot;mglyph&quot;, and
&quot;malignmark&quot; will not be allowed, even if explicitly added to
the allowlist. If applications try to allow any of these tags, a warning
is emitted and the tags
are removed from the allow-list.</p>
<p>The CVEs addressed by this change are:</p>
<ul>
<li>CVE-2024-53988 (GHSA-cfjx-w229-hgx5)</li>
<li>CVE-2024-53989 (GHSA-rxv5-gxqc-xx8g)</li>
</ul>
<p>Please note that we <em>may</em> restore support for allowing
&quot;noscript&quot; in a future release. We do not
expect to ever allow &quot;mglyph&quot; or &quot;malignmark&quot;,
though, especially since browser support is minimal
for these tags.</p>
<p><em>Mike Dalessio</em></p>
</li>
<li>
<p>Improve performance by eliminating needless operations on attributes
that are being removed. <a
href="https://redirect.github.com/rails/rails-html-sanitizer/issues/188">#188</a></p>
<p><em>Mike Dalessio</em></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/5e96b19bbb934284e675109851bd82429622bb6e"><code>5e96b19</code></a>
version bump to v1.6.1</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/383cc7c17f5a6aafed79adff1c419da31a414878"><code>383cc7c</code></a>
doc: update CHANGELOG with assigned CVEs</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/a7b0cfe103ff5d8b17d77fb3660589522dd6bcad"><code>a7b0cfe</code></a>
Combine the noscript/mglyph prevention blocks</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/5658335ede931a47d02b8aa4d3b5800bd3184158"><code>5658335</code></a>
Merge branch 'h1-2509647-noscript' into
flavorjones-2024-security-fixes</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/65fb72f07e875b401bbec479c2a5a3d3b10ad338"><code>65fb72f</code></a>
Merge branch 'h1-2519936-mglyph-foster-parenting' into
flavorjones-2024-secur...</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/3fe22a8b892361c8463f50f4f4a5d08e299c62a2"><code>3fe22a8</code></a>
Merge branch 'h1-2519936-foreign-ns-confusion' into
flavorjones-2024-security...</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/d7a94c125209a9611308fbfdb7420a8786d7e80f"><code>d7a94c1</code></a>
Merge branch 'h1-2503220-nokogiri-serialization' into
flavorjones-2024-securi...</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/3fd6e650f991ce687961da45038b77504114d237"><code>3fd6e65</code></a>
doc: update CHANGELOG</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/16251735e36ebdc302e2f90f2a39cad56879414f"><code>1625173</code></a>
fix: disallow 'noscript' from safe lists</li>
<li><a
href="https://github.com/rails/rails-html-sanitizer/commit/a0a3e8b76b696446ffc6bffcff3bc7b7c6393c72"><code>a0a3e8b</code></a>
fix: disallow 'mglyph' and 'malignmark' from safe lists</li>
<li>Additional commits viewable in <a
href="https://github.com/rails/rails-html-sanitizer/compare/v1.6.0...v1.6.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rails-html-sanitizer&package-manager=bundler&previous-version=1.6.0&new-version=1.6.1)](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>
2024-12-02 18:23:58 -08:00
Sivin VargheseandGitHub 062587487a feat: Improve Contact list (#10522) 2024-12-02 18:23:41 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
86bd339a47 chore(deps): bump vue-i18n from 9.14.0 to 9.14.2 (#10526)
Bumps
[vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n)
from 9.14.0 to 9.14.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/intlify/vue-i18n/releases">vue-i18n's
releases</a>.</em></p>
<blockquote>
<h2>v9.14.2</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>🔒 Security Fixes</h3>
<ul>
<li>fix: XSS vulnerability with prototype pollution on AST: <a
href="https://github.com/intlify/vue-i18n/security/advisories/GHSA-9r9m-ffp6-9x4v">https://github.com/intlify/vue-i18n/security/advisories/GHSA-9r9m-ffp6-9x4v</a></li>
<li>fix: prototype pollusion on deepCopy: <a
href="https://github.com/intlify/vue-i18n/security/advisories/GHSA-hjwq-mjwj-4x6c">https://github.com/intlify/vue-i18n/security/advisories/GHSA-hjwq-mjwj-4x6c</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/intlify/vue-i18n/compare/v9.14.1...v9.14.2">https://github.com/intlify/vue-i18n/compare/v9.14.1...v9.14.2</a></p>
<h2>v9.14.1</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>fix: messages <code>deepCopy</code> mutates <code>src</code>
arguments by <a
href="https://github.com/BobbieGoede"><code>@​BobbieGoede</code></a> in
<a
href="https://redirect.github.com/intlify/vue-i18n/pull/1975">intlify/vue-i18n#1975</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/intlify/vue-i18n/compare/v9.14.0...v9.14.1">https://github.com/intlify/vue-i18n/compare/v9.14.0...v9.14.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/intlify/vue-i18n/commit/5448139375d123c90019f372404de6138ff6b588"><code>5448139</code></a>
release: v9.14.2</li>
<li><a
href="https://github.com/intlify/vue-i18n/commit/af67265181fa776dab006afbab85de6a47a34d66"><code>af67265</code></a>
release: v9.14.1</li>
<li>See full diff in <a
href="https://github.com/intlify/vue-i18n/commits/v9.14.2/packages/vue-i18n">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vue-i18n&package-manager=npm_and_yarn&previous-version=9.14.0&new-version=9.14.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>
2024-12-02 18:16:33 -08:00
Sivin VargheseandGitHub 0ab7accd3f feat: Contact filter preview (#10516)
# Pull Request Template

## Description

**Screenshots**
<img width="986" alt="image"
src="https://github.com/user-attachments/assets/8df44237-ec51-45d3-aed3-518cded42f5d">

<img width="986" alt="image"
src="https://github.com/user-attachments/assets/2213ce2e-2461-41f0-a05a-0f955a4d7e3a">

**Story**
<img width="992" alt="image"
src="https://github.com/user-attachments/assets/f8e25fe2-11e8-4b9b-8d0b-357f9b7b6e39">
2024-12-02 16:22:05 +05:30
Vishnu NarayananandGitHub e95680e800 feat: remove stale ONLINE_PRESENCE contact keys in redis (#9558)
50% of Redis memory size comes from ONLINE_PRESENCE keys. This PR adds a
periodic job to remove stale keys from all accounts.
2024-12-02 15:47:54 +05:30
Sivin VargheseandGitHub 1c12fbceb9 feat: New contacts advanced filter (#10514) 2024-11-29 10:55:15 +05:30
a50e4f1748 feat(v4): Update the design for the contacts list page (#10501)
---------
Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-27 20:07:20 -08:00
25c61aba25 feat(v4): Add new conversation filters component (#10502)
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-27 20:05:54 -08:00
Sojan JoseandGitHub 94c918e468 chore: [Snyk] Security upgrade google-cloud-storage from 1.44.0 to 1.48.0 (#10148)
- bump up gems based on snyk security alerts
2024-11-27 20:31:01 +08:00
Nguyễn Trần ChungandGitHub ea616d6a93 chore: clear apt warning in cwctl (#10488) 2024-11-27 17:08:54 +05:30
35702457ed feat: Update design for report pages (#10506)
<img width="1440" alt="Screenshot 2024-11-26 at 8 38 57 PM"
src="https://github.com/user-attachments/assets/f752157c-6134-42cb-8211-ce636ea9e4d6">
<img width="1439" alt="Screenshot 2024-11-26 at 8 40 47 PM"
src="https://github.com/user-attachments/assets/580b1f61-68bc-489b-9081-b0aeb402f31d">

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-11-27 18:10:15 +08:00
d569713b66 fix: Fix z-index issue on account switcher (#10505)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-26 19:59:16 -08:00
Sojan JoseandGitHub 12a82b6459 fix: avoid Slack file upload API for fallback messages (#10461)
Skip calling the Slack file upload API for message types such as
fallback (e.g., Facebook and location messages) that lack actual file
data in attachments. This prevents unnecessary API calls and resolves a
Sentry error currently occurring in production.

fixes: https://github.com/chatwoot/chatwoot/issues/10460
2024-11-26 12:56:40 +08:00
Sivin VargheseandGitHub b9d888d8ab feat: Add contact header components (#10498) 2024-11-25 19:59:04 -08:00
Sivin VargheseandGitHub ba1b02e274 feat: Add contact empty state components (#10499) 2024-11-25 19:50:33 -08:00
b0287fe389 feat: Add conditions row component (#10496)
---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-25 16:33:12 -08:00
Shivam Mishra e303a33cd2 fix: button styles 2024-08-14 16:27:17 +05:30
Shivam Mishra de3084572a fix: v-if toggle for captain 2024-08-14 16:14:29 +05:30
Shivam Mishra cbb4c29bf3 feat: add empty state for captain 2024-08-14 16:13:57 +05:30
Shivam Mishra 6fda73c609 feat: revert NewHook 2024-08-14 13:44:11 +05:30
Shivam Mishra 76ee84fa09 feat: kickstart new page for captain 2024-08-14 13:43:22 +05:30
Shivam Mishra 1e9d3d0a0e refactor: separate layout component 2024-08-14 13:23:47 +05:30
Shivam Mishra 95cb7cf2c8 Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/captain-inbox-selector 2024-08-14 13:15:31 +05:30
Shivam Mishra 9823a00cd7 style: no need to import defineEmits
it is a compiler macro, and does not need importing
2024-08-14 11:00:36 +05:30
Shivam Mishra 0d7cec57f1 chore: add todo 2024-08-12 14:30:10 +05:30
Shivam Mishra c4b48edbe8 feat: add inbox_ids to captain integration payload 2024-08-12 14:29:57 +05:30
Shivam Mishra d8e89cc713 feat: add isCaptainCheck 2024-08-12 13:04:35 +05:30
Shivam Mishra b97be238cb chore: remove comment 2024-08-07 20:07:32 +05:30
Shivam Mishra 5892bf2caf test: multiple prop in inbox filter 2024-08-07 20:04:49 +05:30
Shivam Mishra b103e89224 feat: remove inbox_ids from captain schema 2024-08-07 20:01:59 +05:30
Shivam Mishra 3530404534 feat: allow multiple prop in inbox filters 2024-08-07 20:01:07 +05:30
Shivam Mishra c6fff300db feat: rename mapGetter to useMapGetter 2024-08-07 19:59:52 +05:30
Shivam Mishra 7898af32b0 feat: check installation config instead of just ENV 2024-08-07 19:02:41 +05:30
210 changed files with 7013 additions and 2655 deletions
+2
View File
@@ -57,3 +57,5 @@ exclude_patterns:
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
- '**/fixtures/**'
- '**/*/fixtures.js'
+1 -1
View File
@@ -49,7 +49,7 @@ gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'google-cloud-storage', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'
##-- gems for database --#
+31 -32
View File
@@ -209,7 +209,7 @@ GEM
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1)
digest-crc (0.6.4)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
domain_name (0.5.20190701)
@@ -284,39 +284,39 @@ GEM
activesupport (>= 6.1)
gmail_xoauth (0.4.3)
oauth (>= 0.3.6)
google-apis-core (0.11.0)
google-apis-core (0.15.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-apis-iamcredentials_v1 (0.22.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.47.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow-v2 (0.31.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-env (2.2.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.3.1)
google-cloud-location (0.6.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.44.0)
google-cloud-storage (1.52.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-apis-core (~> 0.13)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
@@ -331,10 +331,10 @@ GEM
grpc (~> 1.41)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
@@ -452,7 +452,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.22.0)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -462,7 +462,6 @@ GEM
net-smtp
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
@@ -472,7 +471,7 @@ GEM
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
mini_portile2 (2.8.8)
minitest (5.25.1)
mock_redis (0.36.0)
ruby2_keywords
@@ -480,6 +479,7 @@ GEM
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.4.1)
@@ -502,14 +502,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.7)
nokogiri (1.16.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.7-arm64-darwin)
nokogiri (1.16.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin)
nokogiri (1.16.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -599,9 +599,9 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (~> 1.14)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
@@ -824,7 +824,6 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.2)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -886,7 +885,7 @@ DEPENDENCIES
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-storage
google-cloud-storage (>= 1.48.0)
google-cloud-translate-v3 (>= 0.7.0)
groupdate
grpc
+2 -2
View File
@@ -48,7 +48,7 @@
"size": "basic"
}
},
"stack": "heroku-20",
"stack": "heroku-24",
"image": "heroku/ruby",
"addons": [
{
@@ -58,7 +58,7 @@
"plan": "heroku-postgresql:essential-0"
}
],
"stack": "heroku-20",
"stack": "heroku-24",
"buildpacks": [
{
"url": "heroku/nodejs"
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@@ -17,7 +17,7 @@ class Api::V1::ProfilesController < Api::BaseController
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
head :ok
@user.reload
end
def auto_offline
+8
View File
@@ -27,6 +27,14 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
show(id) {
return axios.get(`${this.url}/${id}?include_contact_inboxes=false`);
}
update(id, data) {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
getConversations(contactId) {
return axios.get(`${this.url}/${contactId}/conversations`);
}
@@ -25,7 +25,7 @@
}
.mx-input {
@apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none;
@apply h-[2.5rem] flex border border-solid border-n-weak rounded-md shadow-none;
}
.mx-input:disabled,
@@ -39,7 +39,7 @@
}
.mx-datepicker-main {
@apply border-0 bg-white dark:bg-slate-800;
@apply border-0 bg-n-solid-2 rounded-xl;
.cell {
&.disabled {
@@ -53,6 +53,14 @@
}
}
.mx-calendar+.mx-calendar {
@apply border-l border-n-weak;
}
.mx-datepicker-footer {
@apply border border-n-weak;
}
.mx-time {
@apply border-0 bg-white dark:bg-slate-800;
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div class="w-full max-w-[960px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-4">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -1,46 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import ContactSortMenu from './ContactSortMenu.vue';
import ContactMoreActions from './ContactMoreActions.vue';
defineProps({
isDetailView: {
type: Boolean,
default: false,
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isEmptyState: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['filter', 'update:sort']);
</script>
<template>
<div class="flex items-center gap-2">
<Button
v-if="!isDetailView && !isEmptyState"
icon="i-lucide-list-filter"
color="slate"
size="sm"
variant="ghost"
@click="emit('filter')"
/>
<ContactSortMenu
v-if="!isDetailView && !isEmptyState"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions v-if="!isDetailView" />
</div>
</template>
@@ -1,10 +1,12 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
id: { type: Number, required: true },
@@ -14,48 +16,108 @@ const props = defineProps({
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const { t } = useI18n();
const contactsFormRef = ref(null);
const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
const contactData = ref(getInitialContactData());
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
});
const countryDetails = computed(() => {
const attributes = props.additionalAttributes || {};
const { country, countryCode, city } = attributes;
if (!country && !countryCode) return null;
const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null;
const parts = [
activeCountry.emoji,
city ? `${city},` : null,
activeCountry.name,
].filter(Boolean);
return parts.length ? parts.join(' ') : null;
});
const handleFormUpdate = updatedData => {
emit('updateContact', { id: props.id, updatedData });
Object.assign(contactData.value, updatedData);
};
const onClickViewDetails = async () => {
emit('showContact', props.id);
const handleUpdateContact = () => {
emit('updateContact', contactData.value);
};
const onClickExpand = () => {
emit('toggle');
contactData.value = getInitialContactData();
};
const onClickViewDetails = () => emit('showContact', props.id);
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate text-n-slate-12">
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span
v-if="additionalAttributes?.companyName"
class="i-lucide-briefcase-business size-4 text-n-slate-11"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div class="flex items-center gap-3">
<span v-if="email" class="text-sm text-n-slate-11">{{ email }}</span>
<div v-if="email" class="w-px h-3 bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm text-n-slate-11">
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="countryDetails" class="text-sm truncate text-n-slate-11">
{{ countryDetails }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
@@ -72,7 +134,7 @@ const onClickViewDetails = async () => {
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="emit('toggle')"
@click="onClickExpand"
/>
<template #after>
@@ -80,22 +142,28 @@ const onClickViewDetails = async () => {
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[360px]"
leave-from-class="opacity-100 max-h-[360px]"
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
>
<div v-show="isExpanded" class="w-full">
<div class="p-6 border-t border-n-strong">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
:contact-data="{
id,
name,
email,
phoneNumber,
additionalAttributes,
}"
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
</div>
</transition>
@@ -0,0 +1,89 @@
<script setup>
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
buttonLabel: {
type: String,
default: '',
},
selectedContact: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
return props.selectedContact?.name;
});
const breadcrumbItems = computed(() => {
const items = [
{
label: t('CONTACTS_LAYOUT.HEADER.BREADCRUMB.CONTACTS'),
link: '#',
},
];
if (props.selectedContact) {
items.push({
label: selectedContactName.value,
});
}
return items;
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
</script>
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full mx-auto max-w-[650px]">
<div class="flex items-center justify-between w-full h-20 gap-2">
<Breadcrumb
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div class="w-full py-4 mx-auto max-w-[650px]">
<slot name="default" />
</div>
</main>
</div>
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
</section>
</template>
@@ -1,15 +1,15 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['export']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
@@ -35,20 +35,10 @@ const exportContacts = async () => {
query = filterQueryGenerator(appliedFilters.value);
}
try {
store.dispatch('contacts/export', {
...query,
label: route.params.label || '',
});
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error.message ||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.ERROR_MESSAGE')
);
}
emit('export', {
...query,
label: route.params.label || '',
});
};
const handleDialogConfirm = async () => {
@@ -1,15 +1,13 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['import']);
const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isImportingContact = computed(() => uiFlags.value.isImporting);
@@ -20,7 +18,7 @@ const fileInput = ref(null);
const hasSelectedFile = ref(null);
const selectedFileName = ref('');
const csvUrl = () => '/downloads/import-contacts-sample.csv';
const csvUrl = '/downloads/import-contacts-sample.csv';
const handleFileClick = () => fileInput.value?.click();
@@ -50,16 +48,7 @@ const handleRemoveFile = () => {
const uploadFile = async () => {
if (!hasSelectedFile.value) return;
try {
await store.dispatch('contacts/import', hasSelectedFile.value);
dialogRef.value?.close();
useAlert(t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(error.message ?? t('IMPORT_CONTACTS.ERROR_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
emit('import', hasSelectedFile.value);
};
defineExpose({ dialogRef });
@@ -83,7 +72,7 @@ defineExpose({ dialogRef });
:href="csvUrl"
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample"
download="import-contacts-sample.csv"
class="text-n-blue-text"
>
{{
@@ -92,57 +81,54 @@ defineExpose({ dialogRef });
</a>
</p>
</template>
<template #form>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label class="text-sm text-n-slate-12 whitespace-nowrap">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.LABEL') }}
</label>
<div class="flex items-center justify-between w-full gap-2">
<span v-if="hasSelectedFile" class="text-sm text-n-slate-12">
{{ selectedFileName }}
</span>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label class="text-sm text-n-slate-12 whitespace-nowrap">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.LABEL') }}
</label>
<div class="flex items-center justify-between w-full gap-2">
<span v-if="hasSelectedFile" class="text-sm text-n-slate-12">
{{ selectedFileName }}
</span>
<Button
v-if="!hasSelectedFile"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
"
icon="i-lucide-upload"
color="slate"
variant="ghost"
size="sm"
class="!w-fit"
@click="handleFileClick"
/>
<div v-else class="flex items-center gap-1">
<Button
v-if="!hasSelectedFile"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
"
icon="i-lucide-download"
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')"
color="slate"
variant="ghost"
size="sm"
class="!w-fit"
@click="handleFileClick"
/>
<div v-else class="flex items-center gap-1">
<Button
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')
"
color="slate"
variant="ghost"
size="sm"
@click="handleFileClick"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
color="slate"
variant="ghost"
size="sm"
@click="handleRemoveFile"
/>
</div>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
color="slate"
variant="ghost"
size="sm"
@click="handleRemoveFile"
/>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="text/csv"
class="hidden"
@change="handleFileChange"
/>
</template>
</div>
<input
ref="fileInput"
type="file"
accept="text/csv"
class="hidden"
@change="handleFileChange"
/>
</Dialog>
</template>
@@ -98,7 +98,7 @@ const { t } = useI18n();
:size="32"
rounded-full
/>
<div class="flex flex-col gap-1">
<div class="flex flex-col w-full min-w-0 gap-1">
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.name }}
</span>
@@ -3,7 +3,7 @@ import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
@@ -41,11 +41,11 @@ const FORM_CONFIG = {
};
const SOCIAL_CONFIG = {
FACEBOOK: 'i-ri-facebook-circle-fill',
GITHUB: 'i-ri-github-fill',
INSTAGRAM: 'i-ri-instagram-line',
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
const defaultState = {
@@ -80,6 +80,8 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
return; // Added to prevent state update for new contact form
@@ -92,14 +94,13 @@ const prepareStateBasedOnProps = () => {
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const [firstName = '', lastName = ''] = name.split(' ');
const { firstName, lastName } = splitName(name);
const {
description,
companyName,
countryCode,
country,
city,
description = '',
companyName = '',
countryCode = '',
country = '',
city = '',
socialProfiles = {},
} = additionalAttributes || {};
@@ -203,6 +204,20 @@ const getMessageType = key => {
: 'info';
};
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.name === value);
state.additionalAttributes.countryCode = selectedCountry?.id || '';
emit('update', state);
};
const resetValidation = () => {
v$.value.$reset();
};
const resetForm = () => {
Object.assign(state, defaultState);
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
@@ -211,6 +226,9 @@ watch(() => props.contactData, prepareStateBasedOnProps, {
// Expose state to parent component for avatar upload
defineExpose({
state,
resetValidation,
isFormInvalid,
resetForm,
});
</script>
@@ -220,7 +238,7 @@ defineExpose({
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
<div class="grid w-full grid-cols-2 gap-4">
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
@@ -234,7 +252,7 @@ defineExpose({
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView,
}"
@update:model-value="emit('update', state)"
@update:model-value="handleCountrySelection"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
@@ -248,7 +266,7 @@ defineExpose({
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error)]:!border-transparent' : ''
!isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
}`"
class="w-full"
@input="
@@ -1,16 +1,18 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const contactsFormRef = ref(null);
const contact = ref(null);
const uiFlags = useMapGetter('contacts/getUIFlags');
@@ -22,7 +24,11 @@ const createNewContact = contactItem => {
const handleDialogConfirm = async () => {
if (!contact.value) return;
await store.dispatch('contacts/create', contact.value);
emit('create', contact.value);
};
const onSuccess = () => {
contactsFormRef.value?.resetForm();
dialogRef.value.close();
};
@@ -30,12 +36,16 @@ const closeDialog = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
defineExpose({ dialogRef, contactsFormRef, onSuccess });
</script>
<template>
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
<ContactsForm is-new-contact @update="createNewContact" />
<ContactsForm
ref="contactsFormRef"
is-new-contact
@update="createNewContact"
/>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
@@ -0,0 +1,71 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['create']);
const FILTER_TYPE_CONTACT = 1;
const { t } = useI18n();
const uiFlags = useMapGetter('customViews/getUIFlags');
const isCreating = computed(() => uiFlags.value.isCreating);
const dialogRef = ref(null);
const state = reactive({
name: '',
});
const validationRules = {
name: { required },
};
const v$ = useVuelidate(validationRules, state);
const handleDialogConfirm = async () => {
const isNameValid = await v$.value.$validate();
if (!isNameValid) return;
emit('create', {
name: state.name,
filter_type: FILTER_TYPE_CONTACT,
});
state.name = '';
v$.value.$reset();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.TITLE')"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.CONFIRM')
"
:is-loading="isCreating"
:disable-confirm-button="isCreating"
@confirm="handleDialogConfirm"
>
<Input
v-model="state.name"
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.LABEL')"
:placeholder="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.PLACEHOLDER')
"
:message="
v$.name.$error
? t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR')
: ''
"
:message-type="v$.name.$error ? 'error' : 'info'"
/>
</Dialog>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['delete']);
const FILTER_TYPE_CONTACT = 'contact';
const { t } = useI18n();
const uiFlags = useMapGetter('customViews/getUIFlags');
const isDeleting = computed(() => uiFlags.value.isDeleting);
const dialogRef = ref(null);
const handleDialogConfirm = async () => {
emit('delete', {
filterType: FILTER_TYPE_CONTACT,
});
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.TITLE')"
:description="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.DESCRIPTION')
"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.CONFIRM')
"
:is-loading="isDeleting"
:disable-confirm-button="isDeleting"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,143 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactSortMenu from './components/ContactSortMenu.vue';
import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'search',
'filter',
'update:sort',
'add',
'import',
'export',
'createSegment',
'deleteSegment',
]);
</script>
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
"
color="slate"
size="sm"
class="relative w-8"
variant="ghost"
@click="emit('filter')"
>
<div
v-if="hasActiveFilters && !isSegmentsView"
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
/>
</Button>
<slot name="filter" />
</div>
<Button
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
icon="i-lucide-save"
color="slate"
size="sm"
variant="ghost"
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView"
icon="i-lucide-trash"
color="slate"
size="sm"
variant="ghost"
@click="emit('deleteSegment')"
/>
<ContactSortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions
@add="emit('add')"
@import="emit('import')"
@export="emit('export')"
/>
</div>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
</template>
@@ -0,0 +1,306 @@
<script setup>
import { ref, computed, unref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import countries from 'shared/constants/countries';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import ContactsHeader from 'dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue';
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue';
import CreateSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue';
import DeleteSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue';
import ContactsFilter from 'dashboard/components-next/filter/ContactsFilter.vue';
const props = defineProps({
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
headerTitle: { type: String, default: '' },
segmentsId: { type: [String, Number], default: 0 },
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
});
const emit = defineEmits([
'update:sort',
'search',
'applyFilter',
'clearFilters',
]);
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const createNewContactDialogRef = ref(null);
const contactExportDialogRef = ref(null);
const contactImportDialogRef = ref(null);
const createSegmentDialogRef = ref(null);
const deleteSegmentDialogRef = ref(null);
const showFiltersModal = ref(false);
const appliedFilter = ref([]);
const segmentsQuery = ref({});
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const hasActiveSegments = computed(
() => props.activeSegment && props.segmentsId !== 0
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
contactImportDialogRef.value?.dialogRef.open();
const openContactExportDialog = () =>
contactExportDialogRef.value?.dialogRef.open();
const openCreateSegmentDialog = () =>
createSegmentDialogRef.value?.dialogRef.open();
const openDeleteSegmentDialog = () =>
deleteSegmentDialogRef.value?.dialogRef.open();
const onCreate = async contact => {
try {
await store.dispatch('contacts/create', contact);
createNewContactDialogRef.value?.onSuccess();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SUCCESS_MESSAGE')
);
} catch (error) {
const i18nPrefix = 'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION';
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS_DUPLICATE`));
} else if (error.data.includes('phone_number')) {
useAlert(t(`${i18nPrefix}.PHONE_NUMBER_DUPLICATE`));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
}
}
};
const onImport = async file => {
try {
await store.dispatch('contacts/import', file);
contactImportDialogRef.value?.dialogRef.close();
useAlert(t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(error.message ?? t('IMPORT_CONTACTS.ERROR_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
};
const onExport = async query => {
try {
await store.dispatch('contacts/export', query);
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error.message ||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.ERROR_MESSAGE')
);
}
};
const onCreateSegment = async payload => {
try {
const payloadData = {
...payload,
query: segmentsQuery.value,
};
const response = await store.dispatch('customViews/create', payloadData);
createSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.SUCCESS_MESSAGE')
);
const segmentId = response?.data?.id;
if (!segmentId) return;
// Navigate to the created segment
router.push({
name: 'contacts_dashboard_segments_index',
params: { segmentId },
query: { page: 1 },
});
} catch {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR_MESSAGE')
);
}
};
const onDeleteSegment = async payload => {
try {
await store.dispatch('customViews/delete', {
id: Number(props.segmentsId),
...payload,
});
router.push({
name: 'contacts_dashboard_index',
query: {
page: 1,
},
});
deleteSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.ERROR_MESSAGE')
);
}
};
const closeAdvanceFiltersModal = () => {
showFiltersModal.value = false;
appliedFilter.value = [];
};
const clearFilters = async () => {
emit('clearFilters');
};
const onApplyFilter = async payload => {
payload = useSnakeCase(payload);
segmentsQuery.value = filterQueryGenerator(payload);
emit('applyFilter', filterQueryGenerator(payload));
showFiltersModal.value = false;
};
const onUpdateSegment = async (payload, segmentName) => {
payload = useSnakeCase(payload);
const payloadData = {
...props.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
await store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
};
const setParamsForEditSegmentModal = () => {
return {
countries,
filterTypes: contactFilterItems,
allCustomAttributes: useSnakeCase(contactAttributes.value),
};
};
const initializeSegmentToFilterModal = segment => {
const query = unref(segment)?.query?.payload;
if (!Array.isArray(query)) return;
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
const values = Array.isArray(transformed.values)
? generateValuesForEditCustomViews(
useSnakeCase(filter),
setParamsForEditSegmentModal()
)
: [];
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values,
};
});
appliedFilter.value = [...appliedFilter.value, ...newFilters];
};
const onToggleFilters = () => {
appliedFilter.value = [];
if (hasActiveSegments.value) {
initializeSegmentToFilterModal(props.activeSegment);
} else {
appliedFilter.value = props.hasAppliedFilters
? [...appliedFilters.value]
: [
{
attributeKey: 'name',
filterOperator: 'equal_to',
values: '',
queryOperator: 'and',
attributeModel: 'standard',
},
];
}
showFiltersModal.value = true;
};
defineExpose({
onToggleFilters,
});
</script>
<template>
<ContactsHeader
:show-search="showSearch"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
@add="openCreateNewContactDialog"
@import="openContactImportDialog"
@export="openContactExportDialog"
@filter="onToggleFilters"
@create-segment="openCreateSegmentDialog"
@delete-segment="openDeleteSegmentDialog"
>
<template #filter>
<ContactsFilter
v-if="showFiltersModal"
v-model="appliedFilter"
:segment-name="activeSegmentName"
:is-segment-view="hasActiveSegments"
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@close="closeAdvanceFiltersModal"
@clear-filters="clearFilters"
/>
</template>
</ContactsHeader>
<CreateNewContactDialog ref="createNewContactDialogRef" @create="onCreate" />
<ContactExportDialog ref="contactExportDialogRef" @export="onExport" />
<ContactImportDialog ref="contactImportDialogRef" @import="onImport" />
<CreateSegmentDialog ref="createSegmentDialogRef" @create="onCreateSegment" />
<DeleteSegmentDialog ref="deleteSegmentDialogRef" @delete="onDeleteSegment" />
</template>
@@ -4,9 +4,8 @@ import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactHeader/ContactImportDialog.vue';
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactHeader/ContactExportDialog.vue';
const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n();
@@ -30,19 +29,15 @@ const contactMenuItems = [
icon: 'i-lucide-download',
},
];
const showActionsDropdown = ref(false);
const createNewContactDialogRef = ref(null);
const contactImportDialogRef = ref(null);
const contactExportDialogRef = ref(null);
const handleContactAction = ({ action }) => {
if (action === 'add') {
createNewContactDialogRef.value?.dialogRef.open();
emit('add');
} else if (action === 'import') {
contactImportDialogRef.value?.dialogRef.open();
emit('import');
} else if (action === 'export') {
contactExportDialogRef.value?.dialogRef.open();
emit('export');
}
};
</script>
@@ -60,11 +55,8 @@ const handleContactAction = ({ action }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="right-0 mt-1 w-52 top-full"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
<CreateNewContactDialog ref="createNewContactDialogRef" />
<ContactImportDialog ref="contactImportDialogRef" />
<ContactExportDialog ref="contactExportDialogRef" />
</template>
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
@@ -16,7 +17,9 @@ const props = defineProps({
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
@@ -67,6 +70,7 @@ const orderingMenus = [
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
@@ -105,7 +109,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
const props = defineProps({
activeSegment: { type: Object, default: null },
});
const emit = defineEmits(['clearFilters', 'openFilter']);
const { t } = useI18n();
const route = useRoute();
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const activeSegmentId = computed(() => route.params.segmentId);
const activeSegmentQuery = computed(() => {
const query = props.activeSegment?.query?.payload;
if (!Array.isArray(query)) return [];
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values: transformed.values,
};
});
return newFilters;
});
const hasActiveSegments = computed(
() => props.activeSegment && activeSegmentId.value !== 0
);
const activeFilterQueryData = computed(() => {
return hasActiveSegments.value
? activeSegmentQuery.value
: appliedFilters.value;
});
</script>
<template>
<ActiveFilterPreview
:applied-filters="activeFilterQueryData"
:max-visible-filters="2"
:more-filters-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
count: activeFilterQueryData.length - 2,
})
"
:clear-button-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-[960px] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
</template>
@@ -0,0 +1,106 @@
<script setup>
import { ref } from 'vue';
import ContactHeader from '../ContactHeader.vue';
// Base state controls
const searchValue = ref('');
const activeSort = ref('last_activity_at');
const activeOrdering = ref('');
const onSearch = value => {
searchValue.value = value;
console.log('🔍 Search:', value);
};
const onSort = ({ sort, order }) => {
activeSort.value = sort;
activeOrdering.value = order;
console.log('🔄 Sort changed:', { sort, order });
};
const onFilter = () => {
console.log('🏷️ Filter clicked');
};
const onMessage = () => {
console.log('💬 Message clicked');
};
const onAdd = () => {
console.log('➕ Add contact clicked');
};
const onImport = () => {
console.log('📥 Import contacts clicked');
};
const onExport = () => {
console.log('📤 Export contacts clicked');
};
</script>
<template>
<Story
title="Components/Contacts/ContactHeader"
:layout="{ type: 'grid', width: '900px' }"
>
<Variant title="Default">
<div class="w-full h-[400px]">
<ContactHeader
header-title="Contacts"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
<Variant title="Empty State">
<div class="w-full">
<ContactHeader
:show-search="false"
header-title="Contacts"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
<Variant title="Segment View">
<div class="w-full">
<ContactHeader
:show-search="false"
header-title="Segment: VIP Customers"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,209 +0,0 @@
<script setup>
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ContactActions from 'dashboard/components-next/Contacts/ContactHeader/ContactActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
showPaginationFooter: {
type: Boolean,
default: true,
},
currentPage: {
type: Number,
default: 1,
},
totalItems: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 15,
},
isDetailView: {
type: Boolean,
default: false,
},
selectedContact: {
type: Object,
default: () => ({}),
},
activeSort: {
type: String,
default: '',
},
activeOrdering: {
type: String,
default: '',
},
isEmptyState: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'filter',
'more',
'update:currentPage',
'goToContactsList',
'search',
'sort',
]);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const selectedContactName = computed(() => {
return props.selectedContact?.name;
});
const breadcrumbItems = computed(() => {
const items = [
{
label: t('CONTACTS_LAYOUT.HEADER.BREADCRUMB.CONTACTS'),
link: '#',
},
];
if (props.selectedContact) {
items.push({
label: selectedContactName.value,
});
}
return items;
});
const hasSidebar = computed(() => {
return slots.sidebar && props.isDetailView;
});
const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300"
:class="hasSidebar && `ltr:2xl:ml-56 rtl:2xl:mr-56`"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div
class="w-full mx-auto"
:class="hasSidebar ? `max-w-[650px]` : `max-w-[900px]`"
>
<div class="flex items-center justify-between w-full h-20 gap-2">
<span
v-if="!isDetailView"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div class="flex items-center gap-4">
<div
v-if="!isDetailView && !isEmptyState && isNotSegmentView"
class="flex items-center gap-2"
>
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<ContactActions
:is-empty-state="isEmptyState"
:is-detail-view="isDetailView"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@filter="emit('filter')"
@update:sort="emit('sort', $event)"
@more="emit('more')"
/>
<div v-if="!isDetailView" class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div
class="w-full py-4 mx-auto"
:class="hasSidebar ? `max-w-[650px]` : `max-w-[900px]`"
>
<slot name="default" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
</section>
</template>
@@ -0,0 +1,103 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
currentPage: { type: Number, default: 1 },
totalItems: { type: Number, default: 100 },
itemsPerPage: { type: Number, default: 15 },
activeSort: { type: String, default: '' },
activeOrdering: { type: String, default: '' },
activeSegment: { type: Object, default: null },
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
});
const emit = defineEmits([
'update:currentPage',
'update:sort',
'search',
'applyFilter',
'clearFilters',
]);
const route = useRoute();
const contactListHeaderWrapper = ref(null);
const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index'
);
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
ref="contactListHeaderWrapper"
:show-search="isNotSegmentView"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:header-title="headerTitle"
:active-segment="activeSegment"
:segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView"
@update:sort="emit('update:sort', $event)"
@search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[960px]">
<ContactsActiveFiltersPreview
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
:active-segment="activeSegment"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
/>
<slot name="default" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
</section>
</template>
@@ -46,7 +46,7 @@ const handleUpdate = async value => {
try {
await store.dispatch('contacts/update', {
id: route.params.contactId,
custom_attributes: {
customAttributes: {
[props.attribute.attributeKey]: value,
},
});
@@ -75,11 +75,11 @@ const CurrentAttributeComponent = computed(() => {
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-1"
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
@@ -16,61 +16,50 @@ const { t } = useI18n();
const searchQuery = ref('');
const attributes = useMapGetter('attributes/getAttributesByModelType');
const contactAttributes = computed(() => attributes.value('contact_attribute'));
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
const hasContactAttributes = computed(
() => contactAttributes.value?.length > 0
);
// Convert attribute key value from snake_case to camelCase
const toCamelCase = str =>
str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
const usedAttributes = computed(() => {
if (!contactAttributes.value || !props.selectedContact?.customAttributes) {
const processContactAttributes = (
attributes,
customAttributes,
filterCondition
) => {
if (!attributes.length || !customAttributes) {
return [];
}
return contactAttributes.value
.filter(attribute => {
const camelKey = toCamelCase(attribute.attributeKey);
return props.selectedContact.customAttributes[camelKey] !== undefined;
})
.map(attribute => ({
...attribute,
value:
props.selectedContact.customAttributes[
toCamelCase(attribute.attributeKey)
] ?? '',
}));
return attributes.reduce((result, attribute) => {
const { attributeKey } = attribute;
const meetsCondition = filterCondition(attributeKey, customAttributes);
if (meetsCondition) {
result.push({
...attribute,
value: customAttributes[attributeKey] ?? '',
});
}
return result;
}, []);
};
const usedAttributes = computed(() => {
return processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => key in custom
);
});
const unusedAttributes = computed(() => {
if (!contactAttributes.value || !props.selectedContact?.customAttributes) {
return [];
}
return contactAttributes.value
.filter(attribute => {
const camelKey = toCamelCase(attribute.attributeKey);
const attributeValue = props.selectedContact.customAttributes[camelKey];
// For checkbox type, consider both true/false as valid values
if (attribute.attributeDisplayType === 'checkbox') {
return typeof attributeValue !== 'boolean';
}
// For other types, check if value doesn't exist or is null/undefined
return attributeValue === undefined || attributeValue === null;
})
.map(attribute => ({
...attribute,
value:
props.selectedContact.customAttributes[
toCamelCase(attribute.attributeKey)
] ?? '',
}));
return processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => !(key in custom)
);
});
const filteredUnusedAttributes = computed(() => {
@@ -34,22 +34,19 @@ const contactConversations = computed(() =>
>
<Spinner />
</div>
<div v-else-if="contactConversations.length > 0" class="flex flex-col py-6">
<div
<div
v-else-if="contactConversations.length > 0"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
class="border-b border-n-strong"
>
<ConversationCard
v-if="conversation"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="px-6 !rounded-none dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
/>
</div>
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
@@ -20,7 +20,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['goToContactsList']);
const emit = defineEmits(['goToContactsList', 'resetTab']);
const { t } = useI18n();
const store = useStore();
@@ -74,6 +74,9 @@ const onContactSearch = debounce(
);
const resetState = () => {
if (state.primaryContactId === null) {
emit('resetTab');
}
state.primaryContactId = null;
searchResults.value = [];
isSearching.value = false;
@@ -28,7 +28,7 @@ const handleDelete = () => {
<template>
<div
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
class="flex flex-col gap-2 py-2 mx-6 border-b border-n-strong group/note"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
@@ -0,0 +1,28 @@
<script setup>
import ContactEmptyState from './ContactEmptyState.vue';
</script>
<template>
<Story
title="Components/Contacts/EmptyState"
:layout="{ type: 'grid', width: '900px' }"
>
<!-- Default Story -->
<Variant title="Default">
<ContactEmptyState
title="No contacts found"
subtitle="Create your first contact to get started"
button-label="Add Contact"
/>
</Variant>
<!-- Without Button -->
<Variant title="Without Button">
<ContactEmptyState
title="No contacts"
subtitle="These are your current contacts"
:show-button="false"
/>
</Variant>
</Story>
</template>
@@ -2,10 +2,10 @@
import { ref } from 'vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
defineProps({
title: {
@@ -26,6 +26,8 @@ defineProps({
},
});
const emit = defineEmits(['create']);
const createNewContactDialogRef = ref(null);
const onClick = () => {
@@ -54,8 +56,11 @@ const onClick = () => {
<template #actions>
<div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
<CreateNewContactDialog
ref="createNewContactDialogRef"
@create="emit('create', $event)"
/>
</div>
<CreateNewContactDialog ref="createNewContactDialogRef" />
</template>
</EmptyStateLayout>
</template>
@@ -1,10 +1,9 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { debounce } from '@chatwoot/utils';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -31,41 +30,55 @@ const avatarUrl = ref('');
const contactsFormRef = ref(null);
const selectedContactData = computed(() => {
if (!props.selectedContact) return {};
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
return props.selectedContact;
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const contactData = ref({});
const getInitialContactData = () => {
if (!props.selectedContact) return {};
return { ...props.selectedContact };
};
onMounted(() => {
Object.assign(contactData.value, getInitialContactData());
});
const createdAt = computed(() => {
return props.selectedContact?.createdAt
? dynamicTime(props.selectedContact.createdAt)
return contactData.value?.createdAt
? dynamicTime(contactData.value.createdAt)
: '';
});
const lastActivityAt = computed(() => {
return props.selectedContact?.lastActivityAt
? dynamicTime(props.selectedContact.lastActivityAt)
return contactData.value?.lastActivityAt
? dynamicTime(contactData.value.lastActivityAt)
: '';
});
const avatarSrc = computed(() => {
return avatarUrl.value ? avatarUrl.value : props.selectedContact?.thumbnail;
return avatarUrl.value ? avatarUrl.value : contactData.value?.thumbnail;
});
const updateContact = async updatedData => {
await store.dispatch('contacts/update', updatedData);
await store.dispatch(
'contacts/fetchContactableInbox',
props.selectedContact?.id
);
const handleFormUpdate = updatedData => {
Object.assign(contactData.value, updatedData);
};
const handleFormUpdate = debounce(
updatedData => updateContact(updatedData),
600,
false
);
const updateContact = async () => {
try {
const { customAttributes, ...basicContactData } = contactData.value;
await store.dispatch('contacts/update', basicContactData);
await store.dispatch(
'contacts/fetchContactableInbox',
props.selectedContact.id
);
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.ERROR_MESSAGE'));
}
};
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
@@ -76,7 +89,7 @@ const handleAvatarUpload = async ({ file, url }) => {
avatarUrl.value = url;
try {
await updateContact({
await store.dispatch('contacts/update', {
...contactsFormRef.value?.state,
avatar: file,
isFormData: true,
@@ -95,6 +108,7 @@ const handleAvatarDelete = async () => {
}
avatarFile.value = null;
avatarUrl.value = '';
contactData.value.thumbnail = null;
} catch (error) {
useAlert(
error.message
@@ -110,7 +124,7 @@ const handleAvatarDelete = async () => {
<div class="flex flex-col items-start gap-3">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact.name || ''"
:name="selectedContact?.name || ''"
:size="72"
allow-upload
@upload="handleAvatarUpload"
@@ -118,26 +132,48 @@ const handleAvatarDelete = async () => {
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact.name }}
{{ selectedContact?.name }}
</h3>
<span class="text-sm text-n-slate-11">
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
•
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
<div class="flex flex-col gap-1.5">
<span
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
v-if="selectedContact?.identifier"
class="i-ph-activity text-n-slate-10 size-4"
/>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
•
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
</div>
<ContactLabels :contact-id="selectedContact.id" />
<ContactLabels :contact-id="selectedContact?.id" />
</div>
<div class="flex flex-col items-start gap-6">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
is-details-view
@update="handleFormUpdate"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="updateContact"
/>
</div>
<ContactsForm
ref="contactsFormRef"
:contact-data="selectedContactData"
is-details-view
@update="handleFormUpdate"
/>
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
@@ -1,54 +1,58 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { debounce } from '@chatwoot/utils';
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({
contacts: {
type: Array,
required: true,
},
});
defineProps({ contacts: { type: Array, required: true } });
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const route = useRoute();
// Manage expanded state here
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null);
const updateContact = async ({ id, updatedData }) => {
await store.dispatch('contacts/update', updatedData);
await store.dispatch('contacts/fetchContactableInbox', id);
};
const handleFormUpdate = debounce(
({ id, updatedData }) => updateContact({ id, updatedData }),
600,
false
);
const ROUTE_MAPPINGS = {
contacts_dashboard_labels_index: 'contacts_dashboard_labels_edit_index',
contacts_dashboard_segments_index: 'contacts_dashboard_segments_edit_index',
const updateContact = async updatedData => {
try {
await store.dispatch('contacts/update', updatedData);
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
} catch (error) {
const i18nPrefix = 'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM';
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS.DUPLICATE`));
} else if (error.data.includes('phone_number')) {
useAlert(t(`${i18nPrefix}.PHONE_NUMBER.DUPLICATE`));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
}
}
};
const onClickViewDetails = async id => {
const dynamicRouteName =
ROUTE_MAPPINGS[route.name] || 'contacts_dashboard_edit_index';
const routeTypes = {
contacts_dashboard_segments_index: ['contacts_edit_segment', 'segmentId'],
contacts_dashboard_labels_index: ['contacts_edit_label', 'label'],
};
const [name, paramKey] = routeTypes[route.name] || ['contacts_edit'];
const params = {
contactId: id,
...(paramKey && { [paramKey]: route.params[paramKey] }),
};
const params = { contactId: id };
if (route.name.includes('segments')) {
params.segmentId = route.params.segmentId;
} else if (route.name.includes('labels')) {
params.label = route.params.label;
}
await router.push({ name: dynamicRouteName, params, query: route.query });
await router.push({ name, params, query: route.query });
};
const toggleExpanded = id => {
@@ -57,7 +61,7 @@ const toggleExpanded = id => {
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
@@ -68,8 +72,9 @@ const toggleExpanded = id => {
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@update-contact="handleFormUpdate"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
/>
</div>
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -13,9 +14,15 @@ const props = defineProps({
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage.content || t('CHAT_LIST.NO_CONTENT')
);
});
const assignee = computed(() => {
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
@@ -19,9 +20,15 @@ const props = defineProps({
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage.content || t('CHAT_LIST.NO_CONTENT')
);
});
const assignee = computed(() => {
@@ -82,7 +82,7 @@ const onCardClick = e => {
<template>
<div
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out cursor-pointer rounded-xl"
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
@click="onCardClick"
>
<Avatar
@@ -88,7 +88,7 @@ const handleInputUpdate = async () => {
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
'text-n-slate-12 truncate': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
@@ -127,9 +127,8 @@ const handleInputUpdate = async () => {
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1':
isEditingView && !isAttributeTypeLink,
'truncate flex-1 hover:text-n-brand text-n-blue-text':
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
@@ -169,7 +169,7 @@ watch(
@apply m-0 !important;
&::before {
@apply text-n-slate-10 dark:text-n-slate-10;
@apply text-n-slate-11 dark:text-n-slate-11;
}
}
}
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div class="w-full max-w-[960px] mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -76,6 +76,7 @@ const togglePortalSwitcher = () => {
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
@@ -95,7 +96,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-3">
<div class="w-full max-w-[960px] mx-auto py-3">
<slot name="content" />
</div>
</main>
@@ -182,6 +182,7 @@ onMounted(() => {
<OnClickOutside @trigger="openAgentsList = false">
<Button
variant="ghost"
color="slate"
class="!px-0 font-normal hover:!bg-transparent"
text-variant="info"
@click="openAgentsList = !openAgentsList"
@@ -214,6 +215,7 @@ onMounted(() => {
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
class="!px-2 font-normal hover:!bg-transparent"
@click="openCategoryList = !openCategoryList"
>
@@ -244,6 +246,7 @@ onMounted(() => {
"
icon="i-lucide-plus"
variant="ghost"
color="slate"
:disabled="isNewArticle"
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
@click="openProperties = !openProperties"
@@ -66,6 +66,7 @@ onMounted(() => {
icon="i-lucide-x"
size="sm"
variant="ghost"
color="slate"
class="hover:text-n-slate-11"
@click="emit('close')"
/>
@@ -108,6 +108,7 @@ const redirectToPortalHomePage = () => {
<Button
icon="i-lucide-arrow-up-right"
variant="ghost"
color="slate"
icon-lib="lucide"
size="sm"
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
@@ -133,6 +134,7 @@ const redirectToPortalHomePage = () => {
:key="index"
:label="portal.name"
variant="ghost"
color="slate"
trailing-icon
:icon="isPortalActive(portal) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
@@ -2,7 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
@@ -12,21 +12,29 @@ import {
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const route = useRoute();
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
});
const store = useStore();
const { t } = useI18n();
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
@@ -34,12 +42,19 @@ const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled
);
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const contactId = computed(() => route.params.contactId || null);
const activeContact = computed(() => contactById.value(contactId.value));
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
});
const onContactSearch = debounce(
async query => {
@@ -58,9 +73,12 @@ const onContactSearch = debounce(
false
);
const resetContacts = () => {
contacts.value = [];
};
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
@@ -74,7 +92,6 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
contact = rest;
}
selectedContact.value = contact;
if (contact?.id) {
isFetchingInboxes.value = true;
try {
@@ -89,7 +106,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
onContactSearch('');
resetContacts();
};
const clearSelectedContact = () => {
@@ -101,6 +118,7 @@ const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
targetInbox.value = null;
resetContacts();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
@@ -134,8 +152,7 @@ const toggle = () => {
watch(
activeContact,
() => {
if (activeContact.value && contactId.value) {
// Add null check for contactInboxes
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = {
...activeContact.value,
@@ -146,9 +163,7 @@ watch(
{ immediate: true, deep: true }
);
onMounted(() => {
onContactSearch('');
});
onMounted(() => resetContacts());
const keyboardEvents = {
Escape: {
@@ -159,17 +174,20 @@ const keyboardEvents = {
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="relative">
<div
v-on-click-outside="() => (showComposeNewConversation = false)"
class="relative z-40"
>
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
v-if="showComposeNewConversation"
:contacts="contacts"
@@ -183,7 +201,9 @@ useKeyboardEvents(keyboardEvents);
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@@ -4,6 +4,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
@@ -113,7 +114,6 @@ const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: props.isTwilioSmsInbox,
attachFile: ({ blob, file }) => {
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
@@ -174,7 +174,7 @@ useKeyboardEvents(keyboardEvents);
/>
<div
v-if="!isWhatsappInbox && !hasNoInbox"
v-on-clickaway="() => (isEmojiPickerOpen = false)"
v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative"
>
<Button
@@ -1,170 +0,0 @@
<script setup>
import { ref } from 'vue';
import ComposeNewConversationForm from './ComposeNewConversationForm.vue';
const contacts = [
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: 'offline',
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
];
const selectedContact = ref({
email: 'ozler@chatwoot.com',
id: 29,
label: 'Abraham Ozlers (ozler@chatwoot.com)',
name: 'Abraham Ozlers',
thumbnail: {
name: 'Abraham Ozlers',
src: 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
},
contactInboxes: [
{
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
},
{
id: 8,
label: 'PaperLayer WhatsApp',
name: 'PaperLayer WhatsApp',
email: '+1223233434',
channelType: 'Channel::Whatsapp',
},
{
id: 9,
label: 'PaperLayer API',
name: 'PaperLayer API',
email: '',
channelType: 'Channel::Api',
},
],
});
const targetInbox = ref({
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
});
const currentUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
// Event handlers
const onSearchContacts = query => {
console.log('Searching contacts:', query);
};
const onUpdateSelectedContact = contact => {
console.log('Selected contact updated:', contact);
};
const onUpdateTargetInbox = inbox => {
targetInbox.value = inbox;
console.log('Target inbox updated:', inbox);
};
const onClearSelectedContact = () => {
console.log('Contact cleared');
};
const onCreateConversation = payload => {
console.log('Creating conversation:', payload);
};
const onDiscard = () => {
console.log('Form discarded');
};
</script>
<template>
<Story
title="Components/Compose/ComposeNewConversationForm"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With all props">
<div class="h-[600px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="false"
is-fetching-inboxes
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
<Variant title="With no target inbox">
<div class="h-[200px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="{ ...selectedContact, contactInboxes: [] }"
:target-inbox="null"
:is-creating-contact="false"
:is-fetching-inboxes="false"
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
</Story>
</template>
@@ -59,8 +59,6 @@ const state = reactive({
attachedFiles: [],
});
const showBccInput = ref(false);
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
@@ -143,7 +141,11 @@ const isAnyDropdownActive = computed(() => {
});
const handleContactSearch = value => {
emit('searchContacts', value);
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email', 'phone_number', 'name'],
query: value,
});
};
const handleDropdownUpdate = (type, value) => {
@@ -156,18 +158,14 @@ const handleDropdownUpdate = (type, value) => {
}
};
const toggleBccInput = () => {
showBccInput.value = !showBccInput.value;
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', value);
emit('searchContacts', { keys: ['email'], query: value });
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', value);
emit('searchContacts', { keys: ['email'], query: value });
};
const setSelectedContact = async ({ value, action, ...rest }) => {
@@ -256,7 +254,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="absolute right-0 w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
@@ -294,12 +292,10 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:show-bcc-input="showBccInput"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@toggle-bcc="toggleBccInput"
@update-dropdown="handleDropdownUpdate"
/>
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
@@ -43,20 +42,19 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
const { t } = useI18n();
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
label: `${name} (${email})`,
label: email ? `${name} (${email})` : name,
value: id,
thumbnail: { name, src: thumbnail },
...rest,
@@ -67,7 +65,20 @@ const contactsList = computed(() => {
});
const selectedContactLabel = computed(() => {
return `${props.selectedContact?.name} (${props.selectedContact?.email})`;
const { name, email = '', phoneNumber = '' } = props.selectedContact || {};
if (email) {
return `${name} (${email})`;
}
if (phoneNumber) {
return `${name} (${phoneNumber})`;
}
return name || '';
});
const errorClass = computed(() => {
return props.hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: '';
});
</script>
@@ -75,7 +86,7 @@ const selectedContactLabel = computed(() => {
<div class="relative flex-1 px-4 py-3 overflow-y-visible">
<div class="flex items-baseline w-full gap-3 min-h-7">
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.LABEL') }}
{{ t(`${i18nPrefix}.LABEL`) }}
</label>
<div
@@ -83,9 +94,7 @@ const selectedContactLabel = computed(() => {
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{
t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING')
}}
{{ t(`${i18nPrefix}.CONTACT_CREATING`) }}
</span>
</div>
<div
@@ -95,9 +104,7 @@ const selectedContactLabel = computed(() => {
<span class="text-sm truncate text-n-slate-12">
{{
isCreatingContact
? t(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING'
)
? t(`${i18nPrefix}.CONTACT_CREATING`)
: selectedContactLabel
}}
</span>
@@ -112,11 +119,7 @@ const selectedContactLabel = computed(() => {
</div>
<TagInput
v-else
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.TAG_INPUT_PLACEHOLDER'
)
"
:placeholder="t(`${i18nPrefix}.TAG_INPUT_PLACEHOLDER`)"
mode="single"
:menu-items="contactsList"
:show-dropdown="showContactsDropdown"
@@ -125,12 +128,8 @@ const selectedContactLabel = computed(() => {
allow-create
type="email"
class="flex-1 min-h-7"
:class="
hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: ''
"
@focus="emit('updateDropdown', 'contacts', true)"
:class="errorClass"
focus-on-mount
@input="emit('searchContacts', $event)"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
@@ -10,7 +10,6 @@ const props = defineProps({
contacts: { type: Array, required: true },
showCcEmailsDropdown: { type: Boolean, required: false },
showBccEmailsDropdown: { type: Boolean, required: false },
showBccInput: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
hasErrors: { type: Boolean, default: false },
});
@@ -18,10 +17,17 @@ const props = defineProps({
const emit = defineEmits([
'searchCcEmails',
'searchBccEmails',
'toggleBcc',
'updateDropdown',
]);
const i18nPrefix = `COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS`;
const showBccInput = ref(false);
const toggleBccInput = () => {
showBccInput.value = !showBccInput.value;
};
const subject = defineModel('subject', { type: String, default: '' });
const ccEmails = defineModel('ccEmails', { type: String, default: '' });
const bccEmails = defineModel('bccEmails', { type: String, default: '' });
@@ -56,6 +62,12 @@ const handleCcUpdate = value => {
const handleBccUpdate = value => {
bccEmails.value = value.join(',');
};
const inputClass = computed(() => {
return props.hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: '';
});
</script>
<template>
@@ -63,30 +75,22 @@ const handleBccUpdate = value => {
<div class="flex items-baseline flex-1 w-full h-8 gap-3 px-4 py-3">
<InlineInput
v-model="subject"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.SUBJECT_PLACEHOLDER')
"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.SUBJECT_LABEL')"
:placeholder="t(`${i18nPrefix}.SUBJECT_PLACEHOLDER`)"
:label="t(`${i18nPrefix}.SUBJECT_LABEL`)"
focus-on-mount
:custom-input-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
:custom-input-class="inputClass"
/>
</div>
<div class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8">
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.CC_LABEL') }}
{{ t(`${i18nPrefix}.CC_LABEL`) }}
</label>
<div class="flex items-center w-full gap-3 min-h-7">
<TagInput
:model-value="ccEmailsArray"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.CC_PLACEHOLDER')
"
:placeholder="t(`${i18nPrefix}.CC_PLACEHOLDER`)"
:menu-items="contactEmailsList"
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
@@ -98,12 +102,12 @@ const handleBccUpdate = value => {
@update:model-value="handleCcUpdate"
/>
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_BUTTON')"
:label="t(`${i18nPrefix}.BCC_BUTTON`)"
variant="ghost"
size="sm"
color="slate"
class="flex-shrink-0"
@click="emit('toggleBcc')"
@click="toggleBccInput"
/>
</div>
</div>
@@ -114,13 +118,11 @@ const handleBccUpdate = value => {
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_LABEL') }}
{{ t(`${i18nPrefix}.BCC_LABEL`) }}
</label>
<TagInput
:model-value="bccEmailsArray"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_PLACEHOLDER')
"
:placeholder="t(`${i18nPrefix}.BCC_PLACEHOLDER`)"
:menu-items="contactEmailsList"
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { generateLabelForContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -53,7 +54,7 @@ const targetInboxLabel = computed(() => {
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
>
<span class="text-sm text-n-slate-12">
<span class="text-sm truncate text-n-slate-12">
{{ targetInboxLabel }}
</span>
<Button
@@ -67,7 +68,7 @@ const targetInboxLabel = computed(() => {
</div>
<div
v-else
v-on-clickaway="() => emit('toggleDropdown', false)"
v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Button
@@ -0,0 +1,92 @@
<script setup>
import { ref } from 'vue';
import { contacts, activeContact, emailInbox, currentUser } from './fixtures';
import ComposeNewConversationForm from '../ComposeNewConversationForm.vue';
const selectedContact = ref(activeContact);
const targetInbox = ref(emailInbox);
// Event handlers
const onSearchContacts = query => {
console.log('Searching contacts:', query);
};
const onUpdateSelectedContact = contact => {
console.log('Selected contact updated:', contact);
};
const onUpdateTargetInbox = inbox => {
console.log('Target inbox updated:', inbox);
targetInbox.value = inbox;
console.log('Target inbox updated:', inbox);
};
const onClearSelectedContact = () => {
console.log('Contact cleared');
};
const onCreateConversation = payload => {
console.log('Creating conversation:', payload);
};
const onDiscard = () => {
console.log('Form discarded');
};
</script>
<template>
<Story
title="Components/Compose/ComposeNewConversationForm"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With all props">
<div class="h-[600px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="false"
is-fetching-inboxes
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
<Variant title="With no target inbox">
<div class="h-[200px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="{ ...selectedContact, contactInboxes: [] }"
:target-inbox="null"
:is-creating-contact="false"
:is-fetching-inboxes="false"
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,168 @@
export const contacts = [
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: 'offline',
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
];
export const activeContact = {
email: 'ozler@chatwoot.com',
id: 29,
label: 'Abraham Ozlers (ozler@chatwoot.com)',
name: 'Abraham Ozlers',
thumbnail: {
name: 'Abraham Ozlers',
src: 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
},
contactInboxes: [
{
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
},
{
id: 8,
label: 'PaperLayer WhatsApp',
name: 'PaperLayer WhatsApp',
sourceId: '123456',
phoneNumber: '+1223233434',
channelType: 'Channel::Whatsapp',
messageTemplates: [
{
id: '1',
name: 'shipment_confirmation',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: '{{1}}, great news! Your order {{2}} has shipped.\n\nTracking #: {{3}}\nEstimated delivery: {{4}}\n\nWe will provide updates until delivery.',
type: 'BODY',
example: {
bodyText: [['John', '#12345', 'ZK4539O2311J', 'Jan 1, 2024']],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://www.example.com/',
text: 'Track shipment',
type: 'URL',
},
],
},
],
parameterFormat: 'POSITIONAL',
libraryTemplateName: 'shipment_confirmation_2',
},
{
id: '2',
name: 'otp_test',
status: 'APPROVED',
category: 'AUTHENTICATION',
language: 'en_US',
components: [
{
text: 'Use code *{{1}}* to authorize your transaction.',
type: 'BODY',
example: {
bodyText: [['123456']],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp{{1}}',
text: 'Copy code',
type: 'URL',
example: [
'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp123456',
],
},
],
},
],
parameterFormat: 'POSITIONAL',
libraryTemplateName: 'verify_transaction_1',
messageSendTtlSeconds: 900,
},
{
id: '3',
name: 'hello_world',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
parameterFormat: 'POSITIONAL',
},
],
},
{
id: 9,
label: 'PaperLayer API',
name: 'PaperLayer API',
email: '',
channelType: 'Channel::Api',
},
],
};
export const emailInbox = {
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
};
export const currentUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
@@ -1,10 +1,15 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
export const convertChannelTypeToLabel = channelType => {
const [, type] = channelType.split('::');
return type ? type.charAt(0).toUpperCase() + type.slice(1) : channelType;
const CHANNEL_PRIORITY = {
'Channel::Email': 1,
'Channel::Whatsapp': 2,
'Channel::Sms': 3,
'Channel::TwilioSms': 4,
'Channel::WebWidget': 5,
'Channel::Api': 6,
};
export const generateLabelForContactableInboxesList = ({
@@ -22,29 +27,52 @@ export const generateLabelForContactableInboxesList = ({
) {
return `${name} (${phoneNumber})`;
}
return `${name} (${convertChannelTypeToLabel(channelType)})`;
return name;
};
const transformInbox = ({
name,
id,
email,
channelType,
phoneNumber,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
...rest,
});
export const compareInboxes = (a, b) => {
// Channels that have no priority defined should come at the end.
const priorityA = CHANNEL_PRIORITY[a.channelType] || 999;
const priorityB = CHANNEL_PRIORITY[b.channelType] || 999;
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
};
export const buildContactableInboxesList = contactInboxes => {
if (!contactInboxes) return [];
return contactInboxes.map(
({ name, id, email, channelType, phoneNumber, ...rest }) => ({
id,
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
...rest,
})
);
return contactInboxes.map(transformInbox).sort(compareInboxes);
};
export const getCapitalizedNameFromEmail = email => {
@@ -131,30 +159,38 @@ export const prepareWhatsAppMessagePayload = ({
};
};
export const generateContactQuery = ({ query }) => {
export const generateContactQuery = ({ keys = ['email'], query }) => {
return {
payload: [
{
attribute_key: 'email',
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
};
};
// API Calls
export const searchContacts = async query => {
export const searchContacts = async ({ keys, query }) => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ query })
generateContactQuery({ keys, query })
);
return camelcaseKeys(payload, { deep: true });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
};
export const createNewContact = async email => {
@@ -6,19 +6,6 @@ import * as helpers from '../composeConversationHelper';
vi.mock('dashboard/api/contacts');
describe('composeConversationHelper', () => {
describe('convertChannelTypeToLabel', () => {
it('converts channel type with namespace to capitalized label', () => {
expect(helpers.convertChannelTypeToLabel('Channel::Email')).toBe('Email');
expect(helpers.convertChannelTypeToLabel('Channel::Whatsapp')).toBe(
'Whatsapp'
);
});
it('returns original value if no namespace found', () => {
expect(helpers.convertChannelTypeToLabel('email')).toBe('email');
});
});
describe('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'John Doe',
@@ -59,7 +46,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe (Api)');
).toBe('John Doe');
});
});
@@ -83,6 +70,7 @@ describe('composeConversationHelper', () => {
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-ri-mail-line',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
@@ -211,12 +199,13 @@ describe('composeConversationHelper', () => {
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
expect(helpers.generateContactQuery({ query })).toEqual(expected);
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
@@ -227,12 +216,40 @@ describe('composeConversationHelper', () => {
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
expect(helpers.generateContactQuery({ query: '' })).toEqual(expected);
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
@@ -253,7 +270,10 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('john');
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
expect(result).toEqual([
{
@@ -265,11 +285,80 @@ describe('composeConversationHelper', () => {
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(
undefined,
'name',
helpers.generateContactQuery({ query: 'john' })
);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('searches contacts and returns only contacts with email or phone number', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
{
id: 2,
name: 'Jane Doe',
email: null,
phone_number: null,
created_at: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phone_number: null,
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
// Should only return contacts with either email or phone number
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phoneNumber: null,
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('handles empty search results', async () => {
@@ -285,6 +374,8 @@ describe('composeConversationHelper', () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
phone_number: '+1234567890',
contact_inboxes: [
{
inbox_id: 1,
@@ -307,6 +398,8 @@ describe('composeConversationHelper', () => {
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
phoneNumber: '+1234567890',
contactInboxes: [
{
inboxId: 1,
@@ -370,3 +463,74 @@ describe('composeConversationHelper', () => {
});
});
});
describe('compareInboxes', () => {
it('should sort inboxes by channel priority', () => {
const inboxes = [
{ channelType: 'Channel::Api', name: 'API Inbox' },
{ channelType: 'Channel::Email', name: 'Email Inbox' },
{ channelType: 'Channel::WebWidget', name: 'Widget' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Email');
expect(sorted[1].channelType).toBe('Channel::Whatsapp');
expect(sorted[2].channelType).toBe('Channel::WebWidget');
expect(sorted[3].channelType).toBe('Channel::Api');
});
it('should sort SMS channels correctly', () => {
const inboxes = [
{ channelType: 'Channel::TwilioSms', name: 'Twilio' },
{ channelType: 'Channel::Sms', name: 'Regular SMS' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Sms');
expect(sorted[1].channelType).toBe('Channel::TwilioSms');
});
it('should sort by name when channel types are same', () => {
const inboxes = [
{ channelType: 'Channel::Email', name: 'Support' },
{ channelType: 'Channel::Email', name: 'Marketing' },
{ channelType: 'Channel::Email', name: 'Billing' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(inbox => inbox.name)).toEqual([
'Billing',
'Marketing',
'Support',
]);
});
it('should put channels without priority at the end', () => {
const inboxes = [
{ channelType: 'Channel::Unknown', name: 'Unknown' },
{ channelType: 'Channel::Email', name: 'Email' },
{ channelType: 'Channel::LineChannel', name: 'Line' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(i => i.channelType)).toEqual([
'Channel::Email',
'Channel::Whatsapp',
'Channel::LineChannel',
'Channel::Unknown',
]);
});
it('should handle empty array', () => {
const inboxes = [];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted).toEqual([]);
});
});
@@ -132,7 +132,7 @@ const iconStyles = computed(() => ({
}));
const initialsStyles = computed(() => ({
fontSize: `${props.size / 2}px`,
fontSize: `${Math.min(props.size / 2.5, 24)}px`,
}));
const invalidateCurrentImage = () => {
@@ -114,8 +114,13 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Ghost & Link Variants -->
<Variant title="Ghost & Link Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Ghost Button" variant="ghost" />
<Button label="Ghost with Icon" variant="ghost" icon="i-lucide-plus" />
<Button label="Ghost Button" variant="ghost" color="slate" />
<Button
label="Ghost with Icon"
variant="ghost"
color="slate"
icon="i-lucide-plus"
/>
<Button label="Link Button" variant="link" />
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
</div>
@@ -88,6 +88,7 @@ const STYLE_CONFIG = {
faded:
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:underline outline-transparent',
},
ruby: {
@@ -95,6 +96,7 @@ const STYLE_CONFIG = {
faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:underline outline-transparent',
},
amber: {
@@ -103,6 +105,7 @@ const STYLE_CONFIG = {
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:underline outline-transparent',
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 outline-transparent',
},
slate: {
solid:
@@ -111,6 +114,7 @@ const STYLE_CONFIG = {
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
},
teal: {
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
@@ -118,6 +122,7 @@ const STYLE_CONFIG = {
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:underline outline-transparent',
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 outline-transparent',
},
},
sizes: {
@@ -151,7 +156,7 @@ const STYLE_CONFIG = {
const variantClasses = computed(() => {
const variantMap = {
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[computedColor.value].outline,
faded: STYLE_CONFIG.colors[computedColor.value].faded,
@@ -75,8 +75,8 @@ onMounted(() => {
/>
</div>
<button
v-for="item in filteredMenuItems"
:key="item.action"
v-for="(item, index) in filteredMenuItems"
:key="index"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
@@ -95,7 +95,7 @@ onMounted(() => {
rounded-full
/>
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
@@ -14,22 +14,22 @@ const menuItems = ref([
{
label: 'Contact Support',
icon: 'i-lucide-life-buoy',
click: () => window.alert('Contact Support'),
click: () => console.log('Contact Support'),
},
{
label: 'Keyboard Shortcuts',
icon: 'i-lucide-keyboard',
click: () => window.alert('Keyboard Shortcuts'),
click: () => console.log('Keyboard Shortcuts'),
},
{
label: 'Profile Settings',
icon: 'i-lucide-user-pen',
click: () => window.alert('Profile Settings'),
click: () => console.log('Profile Settings'),
},
{
label: 'Change Appearance',
icon: 'i-lucide-swatch-book',
click: () => window.alert('Change Appearance'),
click: () => console.log('Change Appearance'),
},
{
label: 'Open SuperAdmin',
@@ -40,7 +40,7 @@ const menuItems = ref([
{
label: 'Log Out',
icon: 'i-lucide-log-out',
click: () => window.alert('Log Out'),
click: () => console.log('Log Out'),
},
]);
</script>
@@ -1,7 +1,33 @@
<script setup>
import { computed } from 'vue';
const { strong } = defineProps({
// Use strong prop when this dropdown is stacked inside another dropdown
// Chrome has issues with stacked backdrop-blur, so we need an extra blur layer when stacked
// Also, stacked dropdowns should have a strong border
strong: {
type: Boolean,
default: false,
},
});
const borderClass = computed(() => {
return strong ? 'border-n-strong' : 'border-n-weak';
});
const beforeClass = computed(() => {
if (!strong) return '';
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
// https://issues.chromium.org/issues/40835530
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
});
</script>
<template>
<div class="absolute">
<ul
class="text-sm bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base"
class="text-sm bg-n-alpha-3 backdrop-blur-[100px] border rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base relative"
:class="[borderClass, beforeClass]"
>
<slot />
</ul>
@@ -21,9 +21,9 @@ provideDropdownContext({
</script>
<template>
<div class="relative space-y-2">
<div v-on-click-outside="closeMenu" class="relative space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" v-on-click-outside="closeMenu" class="absolute">
<div v-if="isOpen" class="absolute">
<slot />
</div>
</div>
@@ -27,8 +27,8 @@ const componentIs = computed(() => {
const triggerClick = () => {
if (props.click) {
props.click();
if (!props.preserveOpen) closeMenu();
}
if (!props.preserveOpen) closeMenu();
};
</script>
@@ -0,0 +1,66 @@
<script setup>
import { ref, computed } from 'vue';
import { sampleActiveFilters } from './fixtures/filterTypes';
import ActiveFilterPreview from './ActiveFilterPreview.vue';
const appliedFilters = ref([...sampleActiveFilters]);
const maxVisibleFilters = ref(2);
const moreFiltersLabel = computed(
() => `${appliedFilters.value.length - maxVisibleFilters.value} more filters`
);
</script>
<template>
<Story
title="Components/Filters/ActiveFilterPreview"
:layout="{ type: 'grid', width: '960px' }"
>
<Variant title="Default (2 visible filters)">
<ActiveFilterPreview
:applied-filters="appliedFilters.slice(0, 2)"
:max-visible-filters="2"
clear-button-label="Clear Filters"
more-filters-label=""
@clear-filters="() => {}"
/>
</Variant>
<Variant title="Multiple Filters (with more indicator)">
<ActiveFilterPreview
:applied-filters="appliedFilters"
:max-visible-filters="maxVisibleFilters"
clear-button-label="Clear Filters"
:more-filters-label="moreFiltersLabel"
@clear-filters="() => {}"
/>
</Variant>
<Variant title="Show All Filters">
<ActiveFilterPreview
:applied-filters="appliedFilters"
:max-visible-filters="10"
clear-button-label="Clear Filters"
@clear-filters="() => {}"
/>
</Variant>
<Variant title="Single Filter">
<ActiveFilterPreview
:applied-filters="[appliedFilters[0]]"
:max-visible-filters="maxVisibleFilters"
clear-button-label="Clear Filters"
@clear-filters="() => {}"
/>
</Variant>
<Variant title="With Object Values">
<ActiveFilterPreview
:applied-filters="[appliedFilters[6]]"
:max-visible-filters="maxVisibleFilters"
clear-button-label="Clear Filters"
@clear-filters="() => {}"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,109 @@
<script setup>
import { replaceUnderscoreWithSpace } from './helper/filterHelper.js';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
appliedFilters: { type: Array, default: () => [] },
maxVisibleFilters: { type: Number, default: 2 },
clearButtonLabel: { type: String, default: '' },
moreFiltersLabel: { type: String, default: '' },
showClearButton: { type: Boolean, default: true },
});
const emit = defineEmits(['clearFilters', 'openFilter']);
const shouldCapitalizeFirstLetter = key => {
const lowercaseKeys = ['email'];
return !lowercaseKeys.includes(key);
};
const formatOperatorLabel = operator => {
const operators = {
equal_to: 'is',
not_equal_to: 'is not',
contains: 'contains',
does_not_contain: 'does not contain',
is_present: 'is present',
is_not_present: 'is not present',
is_greater_than: 'is greater than',
is_less_than: 'is less than',
days_before: 'days before',
};
return operators[operator] || replaceUnderscoreWithSpace(operator);
};
const formatFilterValue = value => {
if (!value) return '';
if (Array.isArray(value)) {
return value.join(', ');
}
if (typeof value === 'object' && value.name) {
return value.name;
}
return value;
};
</script>
<template>
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
<template v-for="(filter, index) in appliedFilters" :key="index">
<div
v-if="index < maxVisibleFilters"
class="inline-flex items-center gap-2 h-7"
>
<div
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer"
@click="emit('openFilter')"
>
<span
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
>
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
</span>
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
{{ formatOperatorLabel(filter.filterOperator) }}
</span>
<span
v-if="filter.values"
class="lowercase truncate text-n-slate-12"
:class="{
'first-letter:capitalize': shouldCapitalizeFirstLetter(
filter.attributeKey
),
}"
>
{{ formatFilterValue(filter.values) }}
</span>
</div>
<template
v-if="
index < maxVisibleFilters - 1 && index < appliedFilters.length - 1
"
>
<span
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
>
{{ filter.queryOperator }}
</span>
</template>
</div>
</template>
<div
v-if="appliedFilters.length > maxVisibleFilters"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer"
@click="emit('openFilter')"
>
{{ moreFiltersLabel }}
</div>
<div v-if="showClearButton" class="w-px h-3 rounded-lg bg-n-strong" />
<Button
v-if="showClearButton"
:label="clearButtonLabel"
size="xs"
class="!px-1"
variant="ghost"
@click="emit('clearFilters')"
/>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import ConditionRow from './ConditionRow.vue';
import Button from 'next/button/Button.vue';
import { filterTypes } from './fixtures/filterTypes.js';
const DEFAULT_FILTER = {
attributeKey: 'status',
filterOperator: 'equal_to',
values: [],
queryOperator: 'and',
};
const filters = ref([{ ...DEFAULT_FILTER }]);
const conditionsRef = useTemplateRef('conditionsRef');
const removeFilter = index => {
filters.value.splice(index, 1);
};
const showQueryOperator = true;
const addFilter = () => {
filters.value.push({ ...DEFAULT_FILTER });
};
const saveFilter = () => {
console.log(conditionsRef.value.every(condition => condition.validate()));
};
</script>
<template>
<Story
title="Components/Filters/ConditionRow"
:layout="{ type: 'grid', width: '600px' }"
>
<div class="min-h-[400px] p-2 space-y-2">
<template v-for="(filter, index) in filters" :key="`filter-${index}`">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:show-query-operator="false"
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
v-model:query-operator="filters[index - 1].queryOperator"
:show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
<div class="flex gap-3 mt-2">
<Button sm ghost label="Add Filter" @click="addFilter" />
<Button sm label="Save Filter" @click="saveFilter" />
</div>
</div>
</Story>
</template>
@@ -0,0 +1,201 @@
<script setup>
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import FilterSelect from './inputs/FilterSelect.vue';
import MultiSelect from './inputs/MultiSelect.vue';
import SingleSelect from './inputs/SingleSelect.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { validateSingleFilter } from 'dashboard/helper/validations.js';
// filterTypes: import('vue').ComputedRef<FilterType[]>
const { filterTypes } = defineProps({
showQueryOperator: { type: Boolean, default: false },
filterTypes: { type: Array, required: true },
});
const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const attributeKey = defineModel('attributeKey', {
type: String,
required: true,
});
const values = defineModel('values', {
type: [String, Number, Array, Object],
required: true,
});
const filterOperator = defineModel('filterOperator', {
type: String,
required: true,
});
const queryOperator = defineModel('queryOperator', {
type: String,
required: false,
default: undefined,
validator: value => ['and', 'or'].includes(value),
});
const getFilterFromFilterTypes = key =>
filterTypes.find(filterObj => filterObj.attributeKey === key);
const currentFilter = computed(() =>
getFilterFromFilterTypes(attributeKey.value)
);
const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter.filterOperators.find(
operator => operator.value === selectedOperator
);
if (!operatorFromOptions) {
return filter.filterOperators[0];
}
return operatorFromOptions;
};
const currentOperator = computed(() =>
getOperator(currentFilter.value, filterOperator.value)
);
const getInputType = (operator, filter) =>
operator.inputOverride ?? filter.inputType;
const inputType = computed(() =>
getInputType(currentOperator.value, currentFilter.value)
);
const queryOperatorOptions = computed(() => {
return [
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
value: 'and',
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
},
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
value: 'or',
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
},
];
});
const booleanOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
const validationError = computed(() => {
// TOOD: Migrate validateSingleFilter to use camelcase and then remove useSnakeCase here too
return validateSingleFilter(
useSnakeCase({
attributeKey: attributeKey.value,
filterOperator: filterOperator.value,
values: values.value,
})
);
});
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
* the values and operator remain compatible with the new attribute type. For example,
* switching from a text field to a multi-select should reset the value from '' (empty string)
* to an empty array.
*/
const filter = getFilterFromFilterTypes(newAttributeKey);
const newOperator = getOperator(filter, filterOperator.value);
const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') {
values.value = [];
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
values.value = {};
} else {
values.value = '';
}
filterOperator.value = newOperator.value;
};
watch([attributeKey, values, filterOperator], () => {
showErrors.value = false;
});
const validate = () => {
showErrors.value = true;
return !validationError.value;
};
defineExpose({ validate });
</script>
<template>
<li class="list-none">
<div
class="flex items-center gap-2 rounded-md"
:class="{
'animate-wiggle': showErrors && validationError,
}"
>
<FilterSelect
v-if="showQueryOperator"
v-model="queryOperator"
variant="faded"
hide-icon
class="text-sm"
:options="queryOperatorOptions"
/>
<FilterSelect
v-model="attributeKey"
variant="faded"
:options="filterTypes"
@update:model-value="resetModelOnAttributeKeyChange"
/>
<FilterSelect
v-model="filterOperator"
variant="ghost"
:options="currentFilter.filterOperators"
/>
<template v-if="currentOperator.hasInput">
<MultiSelect
v-if="inputType === 'multiSelect'"
v-model="values"
:options="currentFilter.options"
/>
<SingleSelect
v-else-if="inputType === 'searchSelect'"
v-model="values"
:options="currentFilter.options"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
v-model="values"
disable-search
:options="booleanOptions"
/>
<input
v-else
v-model="values"
:type="inputType === 'date' ? 'date' : 'text'"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</template>
<Button
sm
solid
slate
icon="i-lucide-trash"
@click.stop="emit('remove')"
/>
</div>
<span v-if="showErrors && validationError" class="text-sm text-n-ruby-11">
{{ t(`FILTER.ERRORS.${validationError}`) }}
</span>
</li>
</template>
@@ -0,0 +1,174 @@
<script setup>
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useContactFilterContext } from './contactProvider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Button from 'next/button/Button.vue';
import ConditionRow from './ConditionRow.vue';
const props = defineProps({
isSegmentView: { type: Boolean, default: false },
segmentName: { type: String, default: '' },
});
const emit = defineEmits([
'applyFilter',
'updateSegment',
'close',
'clearFilters',
]);
const { filterTypes } = useContactFilterContext();
const filters = defineModel({
type: Array,
default: [],
});
const segmentNameLocal = ref(props.segmentName);
const DEFAULT_FILTER = {
attributeKey: 'name',
filterOperator: 'equal_to',
values: '',
queryOperator: 'and',
attributeModel: 'standard',
};
const { t } = useI18n();
const store = useStore();
const resetFilter = () => {
emit('clearFilters');
filters.value = [{ ...DEFAULT_FILTER }];
};
const removeFilter = index => {
if (filters.value.length === 1) {
resetFilter();
} else {
filters.value.splice(index, 1);
}
};
const addFilter = () => {
filters.value.push({ ...DEFAULT_FILTER });
};
const conditionsRef = useTemplateRef('conditionsRef');
const isConditionsValid = () => {
return conditionsRef.value.every(condition => condition.validate());
};
const updateSavedSegment = () => {
if (isConditionsValid()) {
emit('updateSegment', filters.value, segmentNameLocal.value);
}
};
function validateAndSubmit() {
if (!isConditionsValid()) return;
store.dispatch(
'contacts/setContactFilters',
useSnakeCase(JSON.parse(JSON.stringify(filters.value)))
);
emit('applyFilter', filters.value);
useTrack(CONTACTS_EVENTS.APPLY_FILTER, {
appliedFilters: filters.value.map(filter => ({
key: filter.attributeKey,
operator: filter.filterOperator,
queryOperator: filter.queryOperator,
})),
});
}
const filterModalHeaderTitle = computed(() => {
return !props.isSegmentView
? t('CONTACTS_LAYOUT.FILTER.TITLE')
: t('CONTACTS_LAYOUT.FILTER.EDIT_SEGMENT');
});
onBeforeUnmount(() => emit('close'));
const outsideClickHandler = [
() => emit('close'),
{ ignore: ['#toggleContactsFilterButton'] },
];
</script>
<template>
<div
v-on-click-outside="outsideClickHandler"
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isSegmentView">
<label class="pb-6 border-b border-n-weak">
<div class="mb-2 text-sm text-n-slate-11">
{{ $t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL') }}
</div>
<input
v-model="segmentNameLocal"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:placeholder="t('CONTACTS_LAYOUT.FILTER.SEGMENT.INPUT_PLACEHOLDER')"
/>
</label>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
:key="`filter-${filter.attributeKey}-0`"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
:key="`filter-${filter.attributeKey}-${index}`"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:query-operator="filters[index - 1].queryOperator"
v-model:values="filter.values"
show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
</ul>
<div class="flex justify-between gap-2">
<Button sm ghost blue @click="addFilter">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
</Button>
<div class="flex gap-2">
<Button sm faded slate @click="resetFilter">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
</Button>
<Button
v-if="isSegmentView"
sm
solid
blue
:disabled="!segmentNameLocal"
@click="updateSavedSegment"
>
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.UPDATE_SEGMENT') }}
</Button>
<Button v-else sm solid blue @click="validateAndSubmit">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.APPLY_FILTERS') }}
</Button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,175 @@
<script setup>
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useConversationFilterContext } from './provider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Button from 'next/button/Button.vue';
import ConditionRow from './ConditionRow.vue';
const props = defineProps({
isFolderView: {
type: Boolean,
default: false,
},
folderName: {
type: String,
default: '',
},
});
const emit = defineEmits(['applyFilter', 'updateFolder', 'close']);
const { filterTypes } = useConversationFilterContext();
const filters = defineModel({
type: Array,
default: [],
});
const folderNameLocal = ref(props.folderName);
const DEFAULT_FILTER = {
attributeKey: 'status',
filterOperator: 'equal_to',
values: [],
queryOperator: 'and',
};
const { t } = useI18n();
const store = useStore();
const resetFilter = () => {
filters.value = [{ ...DEFAULT_FILTER }];
};
const removeFilter = index => {
if (filters.value.length === 1) {
resetFilter();
} else {
filters.value.splice(index, 1);
}
};
const addFilter = () => {
filters.value.push({ ...DEFAULT_FILTER });
};
const conditionsRef = useTemplateRef('conditionsRef');
const isConditionsValid = () => {
return conditionsRef.value.every(condition => condition.validate());
};
const updateSavedCustomViews = () => {
if (isConditionsValid()) {
emit('updateFolder', filters.value, folderNameLocal.value);
}
};
function validateAndSubmit() {
if (!isConditionsValid()) {
return;
}
store.dispatch(
'setConversationFilters',
useSnakeCase(JSON.parse(JSON.stringify(filters.value)))
);
emit('applyFilter', filters.value);
useTrack(CONVERSATION_EVENTS.APPLY_FILTER, {
appliedFilters: filters.value.map(filter => ({
key: filter.attributeKey,
operator: filter.filterOperator,
queryOperator: filter.queryOperator,
})),
});
}
const filterModalHeaderTitle = computed(() => {
return !props.isFolderView
? t('FILTER.TITLE')
: t('FILTER.EDIT_CUSTOM_FILTER');
});
onBeforeUnmount(() => emit('close'));
const outsideClickHandler = [
() => emit('close'),
{ ignore: ['#toggleConversationFilterButton'] },
];
</script>
<template>
<div
v-on-click-outside="outsideClickHandler"
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView">
<label class="border-b border-n-weak pb-6">
<div class="text-n-slate-11 text-sm mb-2">
{{ t('FILTER.FOLDER_LABEL') }}
</div>
<input
v-model="folderNameLocal"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</label>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
:key="`filter-${filter.attributeKey}-0`"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
:key="`filter-${filter.attributeKey}-${index}`"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:query-operator="filters[index - 1].queryOperator"
v-model:values="filter.values"
show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
</ul>
<div class="flex gap-2 justify-between">
<Button sm ghost blue @click="addFilter">
{{ $t('FILTER.ADD_NEW_FILTER') }}
</Button>
<div class="flex gap-2">
<Button sm faded slate @click="resetFilter">
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
</Button>
<Button
v-if="isFolderView"
sm
solid
blue
:disabled="!folderNameLocal"
@click="updateSavedCustomViews"
>
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
</Button>
<Button v-else sm solid blue @click="validateAndSubmit">
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
</Button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,130 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { vOnClickOutside } from '@vueuse/components';
import { useTrack } from 'dashboard/composables';
import NextButton from 'next/button/Button.vue';
export default {
components: {
NextButton,
},
directives: {
onClickOutside: vOnClickOutside,
},
props: {
filterType: {
type: Number,
default: 0,
},
customViewsQuery: {
type: Object,
default: () => {},
},
openLastSavedItem: {
type: Function,
default: () => {},
},
},
emits: ['close'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
show: true,
name: '',
};
},
computed: {
isButtonDisabled() {
return this.v$.name.$invalid;
},
},
validations: {
name: {
required,
minLength: minLength(1),
},
},
methods: {
onClose() {
this.$emit('close');
},
async saveCustomViews() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
try {
await this.$store.dispatch('customViews/create', {
name: this.name,
filter_type: this.filterType,
query: this.customViewsQuery,
});
this.alertMessage =
this.filterType === 0
? this.$t('FILTER.CUSTOM_VIEWS.ADD.API_FOLDERS.SUCCESS_MESSAGE')
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.SUCCESS_MESSAGE');
this.onClose();
useTrack(CONTACTS_EVENTS.SAVE_FILTER, {
type: this.filterType === 0 ? 'folder' : 'segment',
});
} catch (error) {
const errorMessage = error?.message;
this.alertMessage =
errorMessage || this.filterType === 0
? errorMessage
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
} finally {
useAlert(this.alertMessage);
}
this.openLastSavedItem();
},
},
};
</script>
<template>
<div
v-on-click-outside="[
() => $emit('close'),
{ ignore: ['#saveFilterTeleportTarget'] },
]"
class="z-40 max-w-3xl lg:w-[500px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
</h3>
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
<div>
<input
v-model="name"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span
v-if="v$.name.$error"
class="text-xs text-n-ruby-11 ml-1 rtl:mr-1"
>
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
</span>
</div>
<div class="flex flex-row justify-end w-full gap-2">
<NextButton sm solid blue :disabled="isButtonDisabled">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
</NextButton>
<NextButton faded slate sm @click.prevent="onClose">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
</NextButton>
</div>
</form>
</div>
</template>
@@ -0,0 +1,184 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { buildAttributesFilterTypes } from './helper/filterHelper.js';
import countries from 'shared/constants/countries.js';
/**
* @typedef {Object} FilterOption
* @property {string|number} id
* @property {string} name
* @property {import('vue').VNode} [icon]
*/
/**
* @typedef {Object} FilterOperator
* @property {string} value
* @property {string} label
* @property {string} icon
* @property {boolean} hasInput
*/
/**
* @typedef {Object} FilterType
* @property {string} attributeKey - The attribute key
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
* @property {string} attributeName - The attribute name used to display on the UI
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
* @property {'text'|'number'} dataType
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
* @property {'standard'|'additional'|'customAttributes'} attributeModel
*/
/**
* @typedef {Object} FilterGroup
* @property {string} name
* @property {FilterType[]} attributes
*/
/**
* Composable that provides conversation filtering context
* @returns {{ filterTypes: import('vue').ComputedRef<FilterType[]>, filterGroups: import('vue').ComputedRef<FilterGroup[]> }}
*/
export function useContactFilterContext() {
const { t } = useI18n();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const {
equalityOperators,
containmentOperators,
dateOperators,
getOperatorTypes,
} = useOperators();
/**
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const customFilterTypes = computed(() =>
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
);
/**
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const filterTypes = computed(() => [
{
attributeKey: 'name',
value: 'name',
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
inputType: 'plainText',
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'email',
value: 'email',
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'phone_number',
value: 'phone_number',
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'identifier',
value: 'identifier',
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
inputType: 'plainText',
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'country_code',
value: 'country_code',
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
options: countries,
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: 'city',
value: 'city',
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
inputType: 'date',
dataType: 'text',
filterOperators: dateOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
inputType: 'date',
dataType: 'text',
filterOperators: dateOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'referer',
value: 'referer',
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'blocked',
value: 'blocked',
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
inputType: 'searchSelect',
options: [
{
id: 'true',
name: t('CONTACTS_LAYOUT.FILTER.BLOCKED_TRUE'),
},
{
id: 'false',
name: t('CONTACTS_LAYOUT.FILTER.BLOCKED_FALSE'),
},
],
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
...customFilterTypes.value,
]);
return { filterTypes };
}
@@ -0,0 +1,603 @@
export const filterTypes = [
{
attributeKey: 'status',
value: 'status',
attributeName: 'Status',
label: 'Status',
inputType: 'multiSelect',
options: [
{ id: 'open', name: 'Open' },
{ id: 'resolved', name: 'Resolved' },
{ id: 'pending', name: 'Pending' },
{ id: 'snoozed', name: 'Snoozed' },
{ id: 'all', name: 'All' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
value: 'assignee_id',
attributeName: 'Assignee name',
label: 'Assignee name',
inputType: 'searchSelect',
options: [
{ id: 14, name: 'Ben Nugent' },
{ id: 30, name: 'Bruce' },
{ id: 16, name: 'Cathy Simms' },
{ id: 7, name: 'Charles Miner' },
{ id: 10, name: 'Craig D' },
{ id: 9, name: 'Dan Gore' },
{ id: 13, name: 'Danny Cordray' },
{ id: 3, name: 'David Wallace' },
{ id: 4, name: 'Deangelo Vickers' },
{ id: 33, name: 'Devon White' },
{ id: 8, name: 'Ed Truck' },
{ id: 31, name: 'Frank' },
{ id: 29, name: 'Gideon' },
{ id: 24, name: 'Glenn Max' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
value: 'team_id',
attributeName: 'Team name',
label: 'Team name',
inputType: 'searchSelect',
options: [
{ id: 223, name: '💰 sales' },
{ id: 224, name: '💼 management' },
{ id: 225, name: '👩‍💼 administration' },
{ id: 226, name: '🚛 warehouse' },
],
dataType: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
value: 'display_id',
attributeName: 'Conversation identifier',
label: 'Conversation identifier',
inputType: 'plainText',
datatype: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'contains',
label: 'Contains',
hasInput: true,
inputOverride: null,
icon: 'i-ph-superset-of-bold',
},
{
value: 'does_not_contain',
label: 'Does not contain',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-superset-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
value: 'campaign_id',
attributeName: 'Campaign name',
label: 'Campaign name',
inputType: 'searchSelect',
options: [],
datatype: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'labels',
value: 'labels',
attributeName: 'Labels',
label: 'Labels',
inputType: 'multiSelect',
options: [
{ id: 'billing', name: 'billing' },
{ id: 'delivery', name: 'delivery' },
{ id: 'lead', name: 'lead' },
{ id: 'ops-handover', name: 'ops-handover' },
{ id: 'premium-customer', name: 'premium-customer' },
{ id: 'software', name: 'software' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'referer',
value: 'referer',
attributeName: 'Referer link',
label: 'Referer link',
inputType: 'plainText',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'contains',
label: 'Contains',
hasInput: true,
inputOverride: null,
icon: 'i-ph-superset-of-bold',
},
{
value: 'does_not_contain',
label: 'Does not contain',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-superset-of',
},
],
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeName: 'Created at',
label: 'Created at',
inputType: 'date',
dataType: 'text',
filterOperators: [
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
{
value: 'days_before',
label: 'Is x days before',
hasInput: true,
inputOverride: 'plainText',
icon: 'i-ph-calendar-minus-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeName: 'Last activity',
label: 'Last activity',
inputType: 'date',
dataType: 'text',
filterOperators: [
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
{
value: 'days_before',
label: 'Is x days before',
hasInput: true,
inputOverride: 'plainText',
icon: 'i-ph-calendar-minus-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'are_you_a_paid_customer',
value: 'are_you_a_paid_customer',
attributeName: 'Are you a paid customer?',
label: 'Are you a paid customer?',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'date_of_purchase',
value: 'date_of_purchase',
attributeName: 'Date of Purchase',
label: 'Date of Purchase',
inputType: 'date',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'your_website',
value: 'your_website',
attributeName: 'Your website',
label: 'Your website',
inputType: 'plainText',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'are_you_residing_in_india',
value: 'are_you_residing_in_india',
attributeName: 'Are you residing in India?',
label: 'Are you residing in India?',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'cloud',
value: 'cloud',
attributeName: 'Cloud',
label: 'Cloud',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'license_type',
value: 'license_type',
attributeName: 'License Type',
label: 'License Type',
inputType: 'searchSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [
{
id: 'Personal',
name: 'Personal',
},
{
id: 'Enterprise',
name: 'Enterprise',
},
{
id: 'Teams',
name: 'Teams',
},
{
id: 'Professional',
name: 'Professional',
},
],
attributeModel: 'customAttributes',
},
];
export const sampleActiveFilters = [
{
attributeKey: 'name',
filterOperator: 'contains',
values: 'John',
queryOperator: 'and',
},
{
attributeKey: 'email',
filterOperator: 'does_not_contain',
values: 'test@chatwoot.com',
queryOperator: 'or',
},
{
attributeKey: 'phone_number',
filterOperator: 'is_present',
values: '+928383822',
queryOperator: 'and',
},
{
attributeKey: 'created_at',
filterOperator: 'is_greater_than',
values: '2024-01-01',
queryOperator: 'and',
},
{
attributeKey: 'last_activity',
filterOperator: 'days_before',
values: '30',
queryOperator: 'and',
},
{
attributeKey: 'date_of_birth',
filterOperator: 'is_not_present',
values: '',
queryOperator: 'and',
},
{
attributeKey: 'country',
filterOperator: 'not_equal_to',
values: { id: 1, name: 'India' },
queryOperator: 'and',
},
];
@@ -0,0 +1,49 @@
/**
* Determines the input type for a custom attribute based on its key
* @param {string} key - The attribute display type key
* @returns {'date'|'plainText'|'searchSelect'|'booleanSelect'} The corresponding input type
*/
export const getCustomAttributeInputType = key => {
switch (key) {
case 'date':
return 'date';
case 'text':
return 'plainText';
case 'list':
return 'searchSelect';
case 'checkbox':
return 'booleanSelect';
default:
return 'plainText';
}
};
/**
* Builds filter types for custom attributes
* @param {Array} attributes - The attributes array
* @param {Function} getOperatorTypes - Function to get operator types
* @returns {Array} Array of filter types
*/
export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
return attributes.map(attr => ({
attributeKey: attr.attributeKey,
value: attr.attributeKey,
attributeName: attr.attributeDisplayName,
label: attr.attributeDisplayName,
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
filterOperators: getOperatorTypes(attr.attributeDisplayType),
options:
attr.attributeDisplayType === 'list'
? attr.attributeValues.map(item => ({ id: item, name: item }))
: [],
attributeModel: 'customAttributes',
}));
};
/**
* Replaces underscores with spaces in a string
* @param {string} text - The input string
* @returns {string} The string with underscores replaced by spaces
*/
export const replaceUnderscoreWithSpace = text =>
text?.replaceAll('_', ' ') ?? '';
@@ -0,0 +1,137 @@
import {
getCustomAttributeInputType,
buildAttributesFilterTypes,
replaceUnderscoreWithSpace,
} from './filterHelper';
describe('filterHelper', () => {
describe('getCustomAttributeInputType', () => {
it('returns date for date type', () => {
expect(getCustomAttributeInputType('date')).toBe('date');
});
it('returns plainText for text type', () => {
expect(getCustomAttributeInputType('text')).toBe('plainText');
});
it('returns searchSelect for list type', () => {
expect(getCustomAttributeInputType('list')).toBe('searchSelect');
});
it('returns booleanSelect for checkbox type', () => {
expect(getCustomAttributeInputType('checkbox')).toBe('booleanSelect');
});
it('returns plainText for unknown type', () => {
expect(getCustomAttributeInputType('unknown')).toBe('plainText');
});
});
describe('buildAttributesFilterTypes', () => {
const mockGetOperatorTypes = type => {
return type === 'list' ? ['is', 'is_not'] : ['contains', 'not_contains'];
};
it('builds filter types for text attributes', () => {
const attributes = [
{
attributeKey: 'test_key',
attributeDisplayName: 'Test Name',
attributeDisplayType: 'text',
attributeValues: [],
},
];
const result = buildAttributesFilterTypes(
attributes,
mockGetOperatorTypes
);
expect(result).toEqual([
{
attributeKey: 'test_key',
value: 'test_key',
attributeName: 'Test Name',
label: 'Test Name',
inputType: 'plainText',
filterOperators: ['contains', 'not_contains'],
options: [],
attributeModel: 'customAttributes',
},
]);
});
it('builds filter types for list attributes with options', () => {
const attributes = [
{
attributeKey: 'list_key',
attributeDisplayName: 'List Name',
attributeDisplayType: 'list',
attributeValues: ['option1', 'option2'],
},
];
const result = buildAttributesFilterTypes(
attributes,
mockGetOperatorTypes
);
expect(result).toEqual([
{
attributeKey: 'list_key',
value: 'list_key',
attributeName: 'List Name',
label: 'List Name',
inputType: 'searchSelect',
filterOperators: ['is', 'is_not'],
options: [
{ id: 'option1', name: 'option1' },
{ id: 'option2', name: 'option2' },
],
attributeModel: 'customAttributes',
},
]);
});
it('handles multiple attributes', () => {
const attributes = [
{
attributeKey: 'date_key',
attributeDisplayName: 'Date Name',
attributeDisplayType: 'date',
attributeValues: [],
},
{
attributeKey: 'checkbox_key',
attributeDisplayName: 'Checkbox Name',
attributeDisplayType: 'checkbox',
attributeValues: [],
},
];
const result = buildAttributesFilterTypes(
attributes,
mockGetOperatorTypes
);
expect(result).toHaveLength(2);
expect(result[0].inputType).toBe('date');
expect(result[1].inputType).toBe('booleanSelect');
});
it('handles empty attributes array', () => {
const result = buildAttributesFilterTypes([], mockGetOperatorTypes);
expect(result).toEqual([]);
});
});
describe('replaceUnderscoreWithSpace', () => {
it('replaces underscores with spaces', () => {
expect(replaceUnderscoreWithSpace('test_key')).toBe('test key');
});
it('returns empty string if input is null', () => {
expect(replaceUnderscoreWithSpace(null)).toBe('');
});
});
});
@@ -61,7 +61,7 @@ const updateSelected = newValue => {
/>
</slot>
</template>
<DropdownBody class="top-0 min-w-48 z-[999]">
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
@@ -122,7 +122,7 @@ const toggleOption = option => {
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-[999]">
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
@@ -80,7 +80,7 @@ const toggleSelected = option => {
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-56 z-[999]">
<DropdownBody class="top-0 min-w-56 z-50" strong>
<div v-if="!disableSearch" class="relative">
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
@@ -0,0 +1,164 @@
import { computed, h } from 'vue';
import { useI18n } from 'vue-i18n';
/**
* @typedef {Object} FilterOperations
* @property {string} EQUAL_TO - Equals comparison
* @property {string} NOT_EQUAL_TO - Not equals comparison
* @property {string} IS_PRESENT - Present check
* @property {string} IS_NOT_PRESENT - Not present check
* @property {string} CONTAINS - Contains check
* @property {string} DOES_NOT_CONTAIN - Does not contain check
* @property {string} IS_GREATER_THAN - Greater than comparison
* @property {string} IS_LESS_THAN - Less than comparison
* @property {string} DAYS_BEFORE - Days before check
* @property {string} STARTS_WITH - Starts with check
*/
/**
* @typedef {Object} Operator
* @property {string} value - Operator value from FILTER_OPS
* @property {string} label - Translated display label
* @property {import('vue').VNode} icon - Vue icon component instance
* @property {string|null} inputOverride - Input field type override
* @property {boolean} hasInput - Whether operator requires an input value
*/
const FILTER_OPS = {
EQUAL_TO: 'equal_to',
NOT_EQUAL_TO: 'not_equal_to',
IS_PRESENT: 'is_present',
IS_NOT_PRESENT: 'is_not_present',
CONTAINS: 'contains',
DOES_NOT_CONTAIN: 'does_not_contain',
IS_GREATER_THAN: 'is_greater_than',
IS_LESS_THAN: 'is_less_than',
DAYS_BEFORE: 'days_before',
STARTS_WITH: 'starts_with',
};
const NO_INPUT_OPTS = [FILTER_OPS.IS_PRESENT, FILTER_OPS.IS_NOT_PRESENT];
const OPS_INPUT_OVERRIDE = {
[FILTER_OPS.DAYS_BEFORE]: 'plainText',
};
/**
* @type {Record<string, string>}
*/
const filterOperatorIcon = {
[FILTER_OPS.EQUAL_TO]: 'i-ph-equals-bold',
[FILTER_OPS.NOT_EQUAL_TO]: 'i-ph-not-equals-bold',
[FILTER_OPS.IS_PRESENT]: 'i-ph-member-of-bold',
[FILTER_OPS.IS_NOT_PRESENT]: 'i-ph-not-member-of-bold',
[FILTER_OPS.CONTAINS]: 'i-ph-superset-of-bold',
[FILTER_OPS.DOES_NOT_CONTAIN]: 'i-ph-not-superset-of-bold',
[FILTER_OPS.IS_GREATER_THAN]: 'i-ph-greater-than-bold',
[FILTER_OPS.IS_LESS_THAN]: 'i-ph-less-than-bold',
[FILTER_OPS.DAYS_BEFORE]: 'i-ph-calendar-minus-bold',
[FILTER_OPS.STARTS_WITH]: 'i-ph-caret-line-right-bold',
};
/**
* Vue composable providing access to filter operators and related functionality
* @returns {Object} Collection of operators and utility functions
* @property {import('vue').ComputedRef<Record<string, Operator>>} operators - All available operators
* @property {import('vue').ComputedRef<Operator[]>} equalityOperators - Equality comparison operators
* @property {import('vue').ComputedRef<Operator[]>} presenceOperators - Presence check operators
* @property {import('vue').ComputedRef<Operator[]>} containmentOperators - Containment check operators
* @property {import('vue').ComputedRef<Operator[]>} comparisonOperators - Numeric comparison operators
* @property {import('vue').ComputedRef<Operator[]>} dateOperators - Date-specific operators
* @property {(key: 'list'|'text'|'number'|'link'|'date'|'checkbox') => Operator[]} getOperatorTypes - Get operators for a field type
*/
export function useOperators() {
const { t } = useI18n();
/** @type {import('vue').ComputedRef<Record<string, Operator>>} */
const operators = computed(() => {
return Object.values(FILTER_OPS).reduce((acc, value) => {
acc[value] = {
value,
label: t(`FILTER.OPERATOR_LABELS.${value}`),
hasInput: !NO_INPUT_OPTS.includes(value),
inputOverride: OPS_INPUT_OVERRIDE[value] || null,
icon: h('span', {
class: `${filterOperatorIcon[value]} !text-n-blue-text`,
}),
};
return acc;
}, {});
});
/** @type {import('vue').ComputedRef<Array<Operator>>} */
const equalityOperators = computed(() => [
operators.value[FILTER_OPS.EQUAL_TO],
operators.value[FILTER_OPS.NOT_EQUAL_TO],
]);
/** @type {import('vue').ComputedRef<Array<Operator>>} */
const presenceOperators = computed(() => [
operators.value[FILTER_OPS.EQUAL_TO],
operators.value[FILTER_OPS.NOT_EQUAL_TO],
operators.value[FILTER_OPS.IS_PRESENT],
operators.value[FILTER_OPS.IS_NOT_PRESENT],
]);
/** @type {import('vue').ComputedRef<Array<Operator>>} */
const containmentOperators = computed(() => [
operators.value[FILTER_OPS.EQUAL_TO],
operators.value[FILTER_OPS.NOT_EQUAL_TO],
operators.value[FILTER_OPS.CONTAINS],
operators.value[FILTER_OPS.DOES_NOT_CONTAIN],
]);
/** @type {import('vue').ComputedRef<Array<Operator>>} */
const comparisonOperators = computed(() => [
operators.value[FILTER_OPS.EQUAL_TO],
operators.value[FILTER_OPS.NOT_EQUAL_TO],
operators.value[FILTER_OPS.IS_PRESENT],
operators.value[FILTER_OPS.IS_NOT_PRESENT],
operators.value[FILTER_OPS.IS_GREATER_THAN],
operators.value[FILTER_OPS.IS_LESS_THAN],
]);
/** @type {import('vue').ComputedRef<Array<Operator>>} */
const dateOperators = computed(() => [
operators.value[FILTER_OPS.IS_GREATER_THAN],
operators.value[FILTER_OPS.IS_LESS_THAN],
operators.value[FILTER_OPS.DAYS_BEFORE],
]);
/**
* Get operator types based on key
* @param {string} key - Type of operator to get
* @returns {Array<Operator>}
*/
const getOperatorTypes = key => {
switch (key) {
case 'list':
return equalityOperators.value;
case 'text':
return containmentOperators.value;
case 'number':
return equalityOperators.value;
case 'link':
return equalityOperators.value;
case 'date':
return comparisonOperators.value;
case 'checkbox':
return equalityOperators.value;
default:
return equalityOperators.value;
}
};
return {
operators,
equalityOperators,
presenceOperators,
containmentOperators,
comparisonOperators,
dateOperators,
getOperatorTypes,
};
}
@@ -0,0 +1,243 @@
import { computed, h } from 'vue';
import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider';
import { buildAttributesFilterTypes } from './helper/filterHelper';
import countries from 'shared/constants/countries.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
/**
* @typedef {Object} FilterOption
* @property {string|number} id
* @property {string} name
* @property {import('vue').VNode} [icon]
*/
/**
* @typedef {Object} FilterOperator
* @property {string} value
* @property {string} label
* @property {string} icon
* @property {boolean} hasInput
*/
/**
* @typedef {Object} FilterType
* @property {string} attributeKey - The attribute key
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
* @property {string} attributeName - The attribute name used to display on the UI
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
* @property {'text'|'number'} dataType
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
* @property {'standard'|'additional'|'customAttributes'} attributeModel
*/
/**
* @typedef {Object} FilterGroup
* @property {string} name
* @property {FilterType[]} attributes
*/
/**
* Composable that provides conversation filtering context
* @returns {{ filterTypes: import('vue').ComputedRef<FilterType[]>, filterGroups: import('vue').ComputedRef<FilterGroup[]> }}
*/
export function useConversationFilterContext() {
const { t } = useI18n();
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const labels = useMapGetter('labels/getLabels');
const agents = useMapGetter('agents/getAgents');
const inboxes = useMapGetter('inboxes/getInboxes');
const teams = useMapGetter('teams/getTeams');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const {
equalityOperators,
presenceOperators,
containmentOperators,
dateOperators,
getOperatorTypes,
} = useOperators();
/**
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const customFilterTypes = computed(() =>
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
);
/**
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const filterTypes = computed(() => [
{
attributeKey: 'status',
value: 'status',
attributeName: t('FILTER.ATTRIBUTES.STATUS'),
label: t('FILTER.ATTRIBUTES.STATUS'),
inputType: 'multiSelect',
options: ['open', 'resolved', 'pending', 'snoozed', 'all'].map(id => {
return {
id,
name: t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${id}.TEXT`),
};
}),
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
value: 'assignee_id',
attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
inputType: 'searchSelect',
options: agents.value.map(agent => {
return {
id: agent.id,
name: agent.name,
};
}),
dataType: 'text',
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'inbox_id',
value: 'inbox_id',
attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
inputType: 'searchSelect',
options: inboxes.value.map(inbox => {
return {
...inbox,
icon: useChannelIcon(inbox).value,
};
}),
dataType: 'text',
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
value: 'team_id',
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
inputType: 'searchSelect',
options: teams.value,
dataType: 'number',
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
value: 'display_id',
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
inputType: 'plainText',
datatype: 'number',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
value: 'campaign_id',
attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
inputType: 'searchSelect',
options: campaigns.value.map(campaign => ({
id: campaign.id,
name: campaign.title,
})),
datatype: 'number',
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'labels',
value: 'labels',
attributeName: t('FILTER.ATTRIBUTES.LABELS'),
label: t('FILTER.ATTRIBUTES.LABELS'),
inputType: 'multiSelect',
options: labels.value.map(label => {
return {
id: label.title,
name: label.title,
icon: h('span', {
class: `rounded-full`,
style: {
backgroundColor: label.color,
height: '6px',
width: '6px',
},
}),
};
}),
dataType: 'text',
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'browser_language',
value: 'browser_language',
attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
inputType: 'searchSelect',
options: languages,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: 'country_code',
value: 'country_code',
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
options: countries,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
value: 'referer',
attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
label: t('FILTER.ATTRIBUTES.CREATED_AT'),
inputType: 'date',
dataType: 'text',
filterOperators: dateOperators.value,
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
inputType: 'date',
dataType: 'text',
filterOperators: dateOperators.value,
attributeModel: 'standard',
},
...customFilterTypes.value,
]);
return { filterTypes };
}
@@ -36,7 +36,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['enterPress', 'input', 'blur']);
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
const modelValue = defineModel({
type: [String, Number],
@@ -58,6 +58,10 @@ const handleBlur = event => {
emit('blur', event.target.value);
};
const handleFocus = event => {
emit('focus', event.target.value);
};
onMounted(() => {
nextTick(() => {
if (props.focusOnMount) {
@@ -95,6 +99,7 @@ defineExpose({
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
/>
@@ -75,6 +75,7 @@ const pageInfo = computed(() => {
icon="i-lucide-chevrons-left"
variant="ghost"
size="sm"
color="slate"
class="!w-8 !h-6"
:disabled="isFirstPage"
@click="changePage(1)"
@@ -82,6 +83,7 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevron-left"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isFirstPage"
@@ -96,6 +98,7 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevron-right"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@@ -104,6 +107,7 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevrons-right"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@@ -91,13 +91,18 @@ const activeCountry = computed(() =>
const inputBorderClass = computed(() => {
const errorClass =
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
const focusClass =
'has-[:focus]:border-n-brand dark:has-[:focus]:border-n-brand';
if (!props.showBorder) {
return hasError.value ? errorClass : 'border-transparent';
if (hasError.value) return errorClass;
return `border-transparent ${focusClass}`;
}
if (hasError.value) {
return errorClass;
}
return 'has-[:focus]:border-n-brand dark:has-[:focus]:border-n-brand border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak';
return `${focusClass} border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak`;
});
const phoneNumberError = computed(() => {
@@ -7,18 +7,17 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarNotificationBell from './SidebarNotificationBell.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const emit = defineEmits([
'openNotificationPanel',
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
@@ -28,7 +27,6 @@ const { accountScopedRoute } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const enableNewConversation = false;
const toggleShortcutModalFn = show => {
if (show) {
@@ -46,7 +44,7 @@ useSidebarKeyboardShortcuts(toggleShortcutModalFn);
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
localStorage
sessionStorage
);
const setExpandedItem = name => {
@@ -60,11 +58,7 @@ provideSidebarContext({
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
const currentAccountId = useMapGetter('getCurrentAccountId');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
@@ -83,13 +77,6 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const showV4Feature = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CHATWOOT_V4
);
});
const menuItems = computed(() => {
return [
{
@@ -201,119 +188,63 @@ const menuItems = computed(() => {
},
],
},
!showV4Feature.value
? {
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute('contacts_dashboard'),
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('contacts_segments_dashboard', {
id: view.id,
}),
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('contacts_labels_dashboard', {
label: label.title,
}),
})),
},
],
}
: {
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute(
'contacts_dashboard_index',
{},
{
page: 1,
search: undefined,
}
),
activeOn: [
'contacts_dashboard_index',
'contacts_dashboard_edit_index',
],
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute(
'contacts_dashboard_segments_index',
{
segmentId: view.id,
},
{
page: 1,
}
),
activeOn: [
'contacts_dashboard_segments_index',
'contacts_dashboard_segments_edit_index',
],
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute(
'contacts_dashboard_labels_index',
{
label: label.title,
},
{
page: 1,
search: undefined,
}
),
activeOn: [
'contacts_dashboard_labels_index',
'contacts_dashboard_labels_edit_index',
],
})),
},
],
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute(
'contacts_dashboard_index',
{},
{ page: 1, search: undefined }
),
activeOn: ['contacts_dashboard_index', 'contacts_edit'],
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute(
'contacts_dashboard_segments_index',
{ segmentId: view.id },
{ page: 1 }
),
activeOn: [
'contacts_dashboard_segments_index',
'contacts_edit_segment',
],
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute(
'contacts_dashboard_labels_index',
{ label: label.title },
{ page: 1, search: undefined }
),
activeOn: [
'contacts_dashboard_labels_index',
'contacts_edit_label',
],
})),
},
],
},
{
name: 'Reports',
label: t('SIDEBAR.REPORTS'),
@@ -334,11 +265,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CSAT'),
to: accountScopedRoute('csat_reports'),
},
{
name: 'Reports Bot',
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
@@ -364,6 +290,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.REPORTS_SLA'),
to: accountScopedRoute('sla_reports'),
},
{
name: 'Reports Bot',
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
],
},
{
@@ -549,7 +480,7 @@ const menuItems = computed(() => {
<div class="flex gap-2 px-2">
<RouterLink
:to="{ name: 'search' }"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3 dark:bg-n-black/30"
class="flex items-center w-full gap-2 px-2 py-1 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
@@ -561,14 +492,17 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<button
v-if="enableNewConversation"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
>
<span
class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
/>
</button>
<ComposeConversation align-position="right">
<template #trigger="{ toggle }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="toggle"
/>
</template>
</ComposeConversation>
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
@@ -586,12 +520,6 @@ const menuItems = computed(() => {
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<div v-if="false" class="flex items-center">
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarNotificationBell
@open-notification-panel="emit('openNotificationPanel')"
/>
</div>
</section>
</aside>
</template>
@@ -54,7 +54,7 @@ const emitNewAccount = () => {
/>
</button>
</template>
<DropdownBody class="min-w-80">
<DropdownBody class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
<DropdownItem
v-for="account in currentUser.accounts"
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, onMounted, nextTick } from 'vue';
import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
@@ -113,6 +113,13 @@ const toggleTrigger = () => {
}
setExpandedItem(props.name);
};
onMounted(async () => {
await nextTick();
if (hasActiveChild.value) {
setExpandedItem(props.name);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
@@ -120,7 +120,7 @@ const allowedMenuItems = computed(() => {
</div>
</button>
</template>
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
@@ -67,7 +67,7 @@ function updateAutoOffline(autoOffline) {
<template>
<DropdownSection>
<div class="grid gap-0">
<DropdownItem>
<DropdownItem preserve-open>
<div class="flex-grow flex items-center gap-1">
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
</div>
@@ -90,7 +90,7 @@ function updateAutoOffline(autoOffline) {
</div>
</Button>
</template>
<DropdownBody class="min-w-32">
<DropdownBody class="min-w-32 z-20">
<DropdownItem
v-for="status in availabilityStatuses"
:key="status.value"
@@ -1,10 +1,12 @@
<script setup>
import { ref, computed, watch } from 'vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { vOnClickOutside } from '@vueuse/components';
import { email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
@@ -79,17 +81,19 @@ const filteredMenuItems = computed(() => {
item => !tags.value.includes(item.label)
);
// Only show typed value as suggestion if:
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. There are no menu items available
const trimmedNewTag = newTag.value.trim();
if (
// 3. Email validation passes (if type is email) and There are no menu items available
const trimmedNewTag = newTag.value?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!props.isLoading &&
!availableMenuItems.length &&
!props.isLoading
) {
(props.type === 'email' ? !isNewTagInValidType.value : true);
if (shouldShowTypedValue) {
return [
{
label: trimmedNewTag,
@@ -115,7 +119,7 @@ const emitDataOnAdd = emailValue => {
};
const addTag = async () => {
const trimmedTag = newTag.value.trim();
const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
@@ -183,7 +187,7 @@ watch(
watch(
() => newTag.value,
async newValue => {
if (props.type === 'email' && newValue.trim()?.length > 2) {
if (props.type === 'email' && newValue?.trim()?.length > 2) {
await v$.value.$validate();
}
}
@@ -195,7 +199,7 @@ const handleBlur = e => emit('blur', e);
<template>
<div
v-on-clickaway="() => handleClickOutside()"
v-on-click-outside="() => handleClickOutside()"
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@@ -214,9 +218,11 @@ const handleBlur = e => emit('blur', e);
@click.stop="removeTag(index)"
/>
</div>
<div class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full">
<div
v-if="showInput || showDropdownMenu"
class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full"
>
<InlineInput
v-if="showInput"
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
@@ -22,10 +22,10 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import IntersectionObserver from './IntersectionObserver.vue';
@@ -37,9 +37,15 @@ import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useFilter } from 'shared/composables/useFilter';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
@@ -51,12 +57,11 @@ import {
isOnMentionsView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { emitter } from 'shared/helpers/mitt';
import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
@@ -107,7 +112,7 @@ const unAssignedChatsList = useMapGetter('getUnAssignedChats');
const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
const appliedFilters = useMapGetter('getAppliedConversationFilters');
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const folders = useMapGetter('customViews/getConversationCustomViews');
const agentList = useMapGetter('agents/getAgents');
const teamsList = useMapGetter('teams/getTeams');
@@ -371,6 +376,7 @@ function emitConversationLoaded() {
}
function fetchFilteredConversations(payload) {
payload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
@@ -383,6 +389,7 @@ function fetchFilteredConversations(payload) {
}
function fetchSavedFilteredConversations(payload) {
payload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
@@ -393,6 +400,7 @@ function fetchSavedFilteredConversations(payload) {
}
function onApplyFilter(payload) {
payload = useSnakeCase(payload);
resetBulkActions();
foldersQuery.value = filterQueryGenerator(payload);
store.dispatch('conversationPage/reset');
@@ -406,10 +414,11 @@ function closeAdvanceFiltersModal() {
}
function onUpdateSavedFilter(payload, folderName) {
const transformedPayload = useSnakeCase(payload);
const payloadData = {
...unref(activeFolder),
name: unref(folderName),
query: filterQueryGenerator(payload),
query: filterQueryGenerator(transformedPayload),
};
store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
@@ -461,17 +470,19 @@ function initializeExistingFilterToModal() {
currentUserDetails.value,
activeAssigneeTab.value
);
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
if (statusFilter) {
appliedFilter.value = [...appliedFilter.value, statusFilter];
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
}
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
props.conversationInbox,
inbox.value,
props.teamId,
activeTeam.value,
props.label
);
).map(useCamelCase);
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
}
@@ -486,27 +497,47 @@ function initializeFolderToFilterModal(newActiveFolder) {
const query = unref(newActiveFolder)?.query?.payload;
if (!Array.isArray(query)) return;
const newFilters = query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(filter, setParamsForEditFolderModal())
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}));
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
const values = Array.isArray(transformed.values)
? generateValuesForEditCustomViews(
useSnakeCase(filter),
setParamsForEditFolderModal()
)
: [];
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values,
};
});
appliedFilter.value = [...appliedFilter.value, ...newFilters];
}
function initalizeAppliedFiltersToModal() {
appliedFilter.value = [...appliedFilters.value];
}
function onToggleAdvanceFiltersModal() {
if (showAdvancedFilters.value === true) {
closeAdvanceFiltersModal();
return;
}
if (!hasAppliedFilters.value && !hasActiveFolders.value) {
initializeExistingFilterToModal();
}
if (hasActiveFolders.value) {
initializeFolderToFilterModal(activeFolder.value);
}
if (hasAppliedFilters.value) {
initalizeAppliedFiltersToModal();
}
showAdvancedFilters.value = true;
}
@@ -751,7 +782,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
class="flex flex-col flex-shrink-0 border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
@@ -770,12 +801,14 @@ watch(conversationFilters, (newVal, oldVal) => {
@basic-filter-change="onBasicFilterChange"
/>
<AddCustomViews
v-if="showAddFoldersModal"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
<Teleport v-if="showAddFoldersModal" to="#saveFilterTeleportTarget">
<SaveCustomView
v-model="appliedFilter"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
</Teleport>
<DeleteCustomViews
v-if="showDeleteFoldersModal"
@@ -871,22 +904,16 @@ watch(conversationFilters, (newVal, oldVal) => {
</template>
</DynamicScroller>
</div>
<woot-modal
v-model:show="showAdvancedFilters"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<ConversationAdvancedFilter
v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes"
:initial-applied-filters="appliedFilter"
:active-folder-name="activeFolderName"
:on-close="closeAdvanceFiltersModal"
<Teleport v-if="showAdvancedFilters" to="#conversationFilterTeleportTarget">
<ConversationFilter
v-model="appliedFilter"
:folder-name="activeFolderName"
:is-folder-view="hasActiveFolders"
@apply-filter="onApplyFilter"
@update-folder="onUpdateSavedFilter"
@close="closeAdvanceFiltersModal"
/>
</woot-modal>
</Teleport>
</div>
</template>
@@ -62,14 +62,18 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
</div>
<div class="flex items-center gap-1">
<template v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emit('addFolders')"
/>
<div class="relative">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emit('addFolders')"
/>
<div id="saveFilterTeleportTarget" class="absolute mt-2 z-40" />
</div>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
@@ -80,14 +84,21 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
/>
</template>
<template v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emit('filtersModal')"
/>
<div class="relative">
<woot-button
id="toggleConversationFilterButton"
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emit('filtersModal')"
/>
<div
id="conversationFilterTeleportTarget"
class="absolute mt-2 z-40"
/>
</div>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
@@ -97,15 +108,18 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
@click="emit('deleteFolders')"
/>
</template>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emit('filtersModal')"
/>
<div v-else class="relative">
<woot-button
id="toggleConversationFilterButton"
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emit('filtersModal')"
/>
<div id="conversationFilterTeleportTarget" class="absolute mt-2 z-40" />
</div>
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
@change-filter="onBasicFilterChange"
@@ -26,7 +26,13 @@ export default {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message, action }) {
onNewToastMessage({ message: originalMessage, action }) {
// FIX ME: This is a temporary workaround to pass string from functions
// that doesn't have the context of the VueApp.
const usei18n = action?.usei18n;
const duration = action?.duration || this.duration;
const message = usei18n ? this.$t(originalMessage) : originalMessage;
this.snackMessages.push({
key: new Date().getTime(),
message,
@@ -34,7 +40,7 @@ export default {
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, this.duration);
}, duration);
},
},
};
@@ -3,18 +3,20 @@ import { frontendURL } from '../../../../helper/URLHelper';
const contacts = accountId => ({
parentNav: 'contacts',
routes: [
'contacts_dashboard',
'contact_profile_dashboard',
'contacts_segments_dashboard',
'contacts_labels_dashboard',
'contacts_dashboard_index',
'contacts_dashboard_segments_index',
'contacts_dashboard_labels_index',
'contacts_edit',
'contacts_edit_segment',
'contacts_edit_label',
],
menuItems: [
{
icon: 'contact-card-group',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index',
},
],
});
@@ -31,7 +31,7 @@ const primaryMenuItems = accountId => [
label: 'CONTACTS',
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
toStateName: 'contacts_dashboard_index',
},
{
icon: 'arrow-trending-lines',
@@ -27,6 +27,7 @@ const settings = accountId => ({
'settings_inboxes_add_agents',
'settings_inboxes_page_channel',
'settings_integrations_dashboard_apps',
'settings_integrations_captain',
'settings_integrations_integration',
'settings_integrations_slack',
'settings_integrations_webhook',
@@ -134,7 +134,7 @@ export default {
icon: 'number-symbol',
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
key: 'labels',
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
@@ -147,7 +147,7 @@ export default {
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/labels/${label.title}/contacts`
`accounts/${this.accountId}/contacts/labels/${label.title}`
),
})),
};
@@ -194,7 +194,7 @@ export default {
icon: 'folder',
label: 'CUSTOM_VIEWS_SEGMENTS',
hasSubMenu: true,
key: 'custom_view',
key: 'segments',
children: this.customViews
.filter(view => view.filter_type === 'contact')
.map(view => ({
@@ -202,7 +202,7 @@ export default {
label: view.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/contacts/custom_view/${view.id}`
`accounts/${this.accountId}/contacts/segments/${view.id}`
),
})),
};
@@ -247,7 +247,7 @@ export default {
<transition-group
name="menu-list"
tag="ul"
class="pt-2 reset-base list-none"
class="pt-2 list-none reset-base"
>
<SecondaryNavItem
v-for="menuItem in accessibleMenuItems"
@@ -119,6 +119,13 @@ export default {
this.menuItem.toStateName === 'settings_applications'
);
},
isContactsDefaultRoute() {
return (
this.menuItem.toStateName === 'contacts_dashboard_index' &&
(this.$store.state.route.name === 'contacts_dashboard_index' ||
this.$store.state.route.name === 'contacts_edit')
);
},
isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
},
@@ -130,6 +137,7 @@ export default {
this.isAllConversations ||
this.isMentions ||
this.isUnattended ||
this.isContactsDefaultRoute ||
this.isCurrentRoute
) {
return 'bg-woot-25 dark:bg-slate-800 text-woot-500 dark:text-woot-500 hover:text-woot-500 dark:hover:text-woot-500 active-view';
@@ -242,7 +250,7 @@ export default {
</span>
</router-link>
<ul v-if="hasSubMenu" class="reset-base list-none">
<ul v-if="hasSubMenu" class="list-none reset-base">
<SecondaryChildNavItem
v-for="child in menuItem.children"
:key="child.id"
@@ -54,7 +54,7 @@ const end = computed(() => {
<div class="flex items-center justify-between">
<div class="flex flex-1 items-center justify-between">
<div>
<p class="text-sm text-gray-700">
<p class="text-sm text-n-slate-11 mb-0">
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
</p>
</div>
@@ -62,7 +62,7 @@ const end = computed(() => {
<woot-button
:disabled="!table.getCanPreviousPage()"
variant="clear"
class="size-8 flex items-center border border-slate-50"
class="h-8 border-0 flex items-center"
color-scheme="secondary"
@click="table.setPageIndex(0)"
>
@@ -70,7 +70,7 @@ const end = computed(() => {
</woot-button>
<woot-button
variant="clear"
class="size-8 flex items-center border border-slate-50"
class="h-8 border-0 flex items-center"
color-scheme="secondary"
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
@@ -81,22 +81,22 @@ const end = computed(() => {
v-for="page in visiblePages"
:key="page"
variant="clear"
class="size-8 flex items-center justify-center border text-xs leading-none text-center"
:class="page == currentPage ? 'border-woot-500' : 'border-slate-50'"
class="h-8 flex items-center justify-center text-xs leading-none text-center"
:class="page == currentPage ? 'border-n-brand' : 'border-slate-50'"
color-scheme="secondary"
@click="table.setPageIndex(page - 1)"
>
<div
<span
class="text-center"
:class="{ 'text-woot-500': page == currentPage }"
:class="{ 'text-n-brand': page == currentPage }"
>
{{ page }}
</div>
</span>
</woot-button>
<woot-button
:disabled="!table.getCanNextPage()"
variant="clear"
class="size-8 flex items-center border border-slate-50"
class="h-8 border-0 flex items-center"
color-scheme="secondary"
@click="table.nextPage()"
>
@@ -105,7 +105,7 @@ const end = computed(() => {
<woot-button
:disabled="!table.getCanNextPage()"
variant="clear"
class="size-8 flex items-center border border-slate-50"
class="h-8 border-0 flex items-center"
color-scheme="secondary"
@click="table.setPageIndex(table.getPageCount() - 1)"
>
@@ -1,8 +1,9 @@
<script setup>
import { FlexRender } from '@tanstack/vue-table';
import SortButton from './SortButton.vue';
import { computed } from 'vue';
defineProps({
const props = defineProps({
table: {
type: Object,
required: true,
@@ -11,22 +12,36 @@ defineProps({
type: Boolean,
default: false,
},
type: {
type: String,
default: 'relaxed',
},
});
const isRelaxed = computed(() => props.type === 'relaxed');
const headerClass = computed(() =>
isRelaxed.value
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
: ''
);
</script>
<template>
<table :class="{ 'table-fixed': fixed }">
<thead
class="sticky top-0 z-10 border-b border-slate-50 dark:border-slate-800 bg-slate-25 dark:bg-slate-800"
>
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<thead class="sticky top-0 z-10 bg-n-slate-1">
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
class="rounded-xl"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:style="{
width: `${header.getSize()}px`,
}"
class="text-left py-3 px-5 dark:bg-slate-800 text-slate-800 dark:text-slate-200 font-normal text-xs"
class="text-left py-3 px-5 font-normal text-sm"
:class="headerClass"
@click="header.column.getCanSort() && header.column.toggleSorting()"
>
<div
@@ -43,16 +58,12 @@ defineProps({
</tr>
</thead>
<tbody class="divide-y divide-slate-25 dark:divide-slate-900">
<tr
v-for="row in table.getRowModel().rows"
:key="row.id"
class="hover:bg-slate-25 dark:hover:bg-slate-800"
>
<tbody class="divide-y divide-n-slate-2">
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="py-2 px-5"
:class="isRelaxed ? 'py-4 px-5' : 'py-2 px-5'"
>
<FlexRender
:render="cell.column.columnDef.cell"

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