Compare commits

..
Author SHA1 Message Date
Sivin VargheseandGitHub 1652b477c8 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-27 22:29:11 +05:30
Pranav 39a6532874 Remove show.llm 2024-11-25 18:45:44 -08:00
Pranav b0504c5c04 Merge branch 'develop' into feat/contacts-redesign 2024-11-25 18:45:10 -08:00
Pranav 8efb760f97 Merge branch 'develop' into feat/contacts-redesign 2024-11-24 19:48:09 -08:00
Sivin VargheseandGitHub ba6d291e3b Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 10:30:45 +05:30
iamsivin 7eac4f241b chore: Fix build 2024-11-22 09:37:23 +05:30
Sivin VargheseandGitHub 137f5616bd Merge branch 'develop' into feat/contacts-redesign 2024-11-22 09:28:44 +05:30
iamsivin f375d962ab chore: Minor fix 2024-11-22 09:28:15 +05:30
iamsivin 7c94da3bc3 Revert "feat: Add Label Input components (#10480)" This reverts commit 2dae4b22a2. 2024-11-22 09:26:32 +05:30
Sivin VargheseandGitHub 6b5b53a722 chore: Fix code climate 2024-11-22 09:09:53 +05:30
Sivin VargheseandGitHub 4156881dec Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 09:04:27 +05:30
iamsivin 93d1e2a032 chore: Update contact card with company name 2024-11-22 09:03:46 +05:30
iamsivin ca05be1c1a chore: Decamelize keys on contact update 2024-11-22 09:03:07 +05:30
Sivin VargheseandGitHub 7c09c42bde Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 08:13:07 +05:30
Sivin VargheseandGitHub ceaeba84ee Merge branch 'develop' into feat/contacts-redesign 2024-11-22 08:09:28 +05:30
Sivin VargheseandGitHub 67fba33005 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 22:55:50 +05:30
Sivin VargheseandGitHub 92190ec81b Merge branch 'develop' into feat/contacts-redesign 2024-11-21 22:55:36 +05:30
iamsivin 2d0c813300 chore: Minor fix 2024-11-21 15:54:01 +05:30
iamsivin 7083137c5a chore: Label components 2024-11-21 15:48:43 +05:30
iamsivin b9b3c43d63 feat: Contact note item component 2024-11-21 15:25:06 +05:30
iamsivin 2a724cad7c chore: Create contact merge component 2024-11-21 14:52:19 +05:30
iamsivin ef51083b3c chore: Minor fix 2024-11-21 13:34:42 +05:30
iamsivin edfb1029a7 chore: Minor fix 2024-11-21 12:43:54 +05:30
Sivin VargheseandGitHub 75cf5322c9 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 12:12:03 +05:30
Sivin VargheseandGitHub 634a31a7ab Merge branch 'develop' into feat/contacts-redesign 2024-11-21 12:11:35 +05:30
iamsivin ee55e8e8ff chore: Minor fix 2024-11-21 10:58:09 +05:30
iamsivin 1a4f44555f chore: Minor fix 2024-11-21 10:37:20 +05:30
iamsivin b919afa182 chore: Minor fix 2024-11-21 00:53:45 +05:30
Sivin VargheseandGitHub 189aa06fa3 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 00:50:28 +05:30
iamsivin 257a5ccf83 chore: Minor fix 2024-11-21 00:42:01 +05:30
iamsivin e3f50da277 chore: Minor fix 2024-11-21 00:37:12 +05:30
iamsivin edcc15fab2 chore: Custom attributes story 2024-11-21 00:24:08 +05:30
iamsivin 525a979283 chore: Minor fix 2024-11-21 00:08:03 +05:30
iamsivin 7021a85da5 chore: Minor fix 2024-11-20 23:33:17 +05:30
Sivin VargheseandGitHub 93cd88253d Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-20 22:36:58 +05:30
iamsivin 2852f01026 chore: Minor fix 2024-11-20 22:34:49 +05:30
Sivin VargheseandGitHub 2dcb6ec997 Merge branch 'develop' into feat/contacts-redesign 2024-11-20 22:33:04 +05:30
Sivin VargheseandGitHub 2538e161a2 Merge branch 'develop' into feat/contacts-redesign 2024-11-20 22:32:01 +05:30
iamsivin 6f4b37a6cd chore: Minor fix 2024-11-20 20:31:07 +05:30
Sivin VargheseandGitHub b9cb4cc65e Merge branch 'develop' into feat/contacts-redesign 2024-11-20 20:26:27 +05:30
iamsivin e46d704573 chore: Minor fix 2024-11-20 17:03:50 +05:30
Sivin VargheseandGitHub 8f7589f173 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-20 08:15:51 +05:30
Sivin VargheseandGitHub f5a72c58db Merge branch 'develop' into feat/contacts-redesign 2024-11-20 08:15:33 +05:30
Sivin VargheseandGitHub 62f357fc7f Merge branch 'develop' into feat/contacts-redesign 2024-11-20 07:07:10 +05:30
Sivin VargheseandGitHub 4fda057afb Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 23:01:53 +05:30
Sivin VargheseandGitHub fccfa5adba Merge branch 'develop' into feat/contacts-redesign 2024-11-19 22:47:59 +05:30
iamsivin 60fdab4bbe chore: More story case 2024-11-19 22:36:03 +05:30
iamsivin 93eb295152 chore: Adds story 2024-11-19 22:31:51 +05:30
iamsivin 1982cb159c chore: Clean up 2024-11-19 22:03:54 +05:30
Sivin VargheseandGitHub ca46ed9bca Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 08:56:25 +05:30
Sivin VargheseandGitHub e591f14729 Merge branch 'develop' into feat/contacts-redesign 2024-11-19 08:56:02 +05:30
Sivin VargheseandGitHub 7d0bfabb49 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 08:28:34 +05:30
Sivin VargheseandGitHub aef2ffc3a9 Merge branch 'develop' into feat/contacts-redesign 2024-11-19 08:28:24 +05:30
iamsivin c3cf73de85 chore: Minor fix 2024-11-19 00:06:23 +05:30
iamsivin 610b05ed5a feat: Attachment preview 2024-11-18 23:47:58 +05:30
iamsivin 2e30d4b825 chore: Minor fix 2024-11-18 22:10:23 +05:30
Sivin VargheseandGitHub 99c762404e Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-18 21:04:19 +05:30
Sivin VargheseandGitHub b6ad0211e3 Merge branch 'develop' into feat/contacts-redesign 2024-11-18 21:03:53 +05:30
iamsivin 15470506bf chore: Minor fixes 2024-11-18 17:18:58 +05:30
Sivin VargheseandGitHub f15c7a8df1 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-15 23:00:49 +05:30
Sivin VargheseandGitHub dceb616cc4 Merge branch 'develop' into feat/contacts-redesign 2024-11-15 23:00:34 +05:30
iamsivin 14e7950186 feat: Inbox empty state warning 2024-11-15 22:56:35 +05:30
iamsivin e3602acf68 chore: Created file upload composable and use it in compose view 2024-11-15 22:12:16 +05:30
iamsivin b97ee9675e chore: Fix build 2024-11-15 18:27:24 +05:30
iamsivin 1b7317b71c chore: Adds translations 2024-11-15 18:21:39 +05:30
iamsivin 7cc0d6d457 chore: Clean up 2024-11-15 17:22:48 +05:30
iamsivin 28481c45cb feat: Code clean up 2024-11-15 17:12:39 +05:30
iamsivin 7790b88725 chore: Fix whats app template 2024-11-15 14:30:46 +05:30
iamsivin 9ea992fe45 feat(v4): Compose new conversation 2024-11-15 02:06:20 +05:30
Sivin VargheseandGitHub bf59285041 Merge branch 'develop' into feat/contacts-redesign 2024-11-14 18:41:22 +05:30
iamsivin 90162ad39b feat: Conversation card click 2024-11-14 18:40:47 +05:30
Sivin VargheseandGitHub bd691d2e2b Merge branch 'develop' into feat/contacts-redesign 2024-11-13 16:57:50 +05:30
iamsivin 94142acb93 chore: Minor fix 2024-11-13 15:55:16 +05:30
iamsivin 78e6f995eb chore: Filter enhancements 2024-11-13 10:05:06 +05:30
Sivin VargheseandGitHub 4cb9abaf8c Merge branch 'develop' into feat/contacts-redesign 2024-11-13 08:19:24 +05:30
iamsivin b75afc2b29 chore: Minor fix 2024-11-12 22:38:49 +05:30
iamsivin 3f608ece5a fix: Phone number input 2024-11-12 22:18:55 +05:30
iamsivin 72496ac4a0 chore: Minor fix 2024-11-12 22:06:08 +05:30
iamsivin 909755c340 chore: Clean up 2024-11-12 22:02:26 +05:30
iamsivin 42a9e8dc82 chore: Minor fix 2024-11-12 21:26:33 +05:30
iamsivin f0c5d214e7 chore: Minor fix 2024-11-12 21:11:37 +05:30
iamsivin cc6ac082b1 feat: Phone input 2024-11-12 21:03:56 +05:30
Sivin VargheseandGitHub d8bc4ae40f Merge branch 'develop' into feat/contacts-redesign 2024-11-12 11:40:26 +05:30
iamsivin 7aa22eabf8 chore: Fix sidebar activeOn issue 2024-11-12 11:39:59 +05:30
iamsivin 1dd690e8f9 chore: Minor fix 2024-11-12 09:14:27 +05:30
iamsivin 6caf7ded2d chore: Minor fix 2024-11-12 09:08:21 +05:30
iamsivin b39e25f9e2 chore: Fix empty state 2024-11-11 23:31:32 +05:30
iamsivin bda9f0208a chore: Fix build 2024-11-11 23:11:37 +05:30
iamsivin 74a90c0173 chore: Fix sidebar 2024-11-11 23:09:30 +05:30
iamsivin 9210d2cecd chore: Minor fix 2024-11-11 22:49:16 +05:30
iamsivin fd4b1f2d21 feat: Contact Empty state 2024-11-11 22:38:08 +05:30
iamsivin ae804baf9b chore: More changes 2024-11-11 21:51:32 +05:30
iamsivin e2c1e6ff00 chore: Fix warning 2024-11-11 21:26:21 +05:30
iamsivin 2dbe5bd1c0 chore: Fix validations for custom attributes 2024-11-11 21:24:34 +05:30
iamsivin 0fe6d404b9 feat: Contact attributes 2024-11-11 21:07:12 +05:30
iamsivin d6cc5422d0 feat: Add avatar upload and merge EditableAvatar.vue with Avatar.vue 2024-11-09 15:30:52 +05:30
iamsivin 271b322761 feat: Contact exporting 2024-11-09 14:06:33 +05:30
iamsivin 1b013544a8 chore: Fix sidebar filter issue and sort reactivity issue 2024-11-09 13:39:22 +05:30
iamsivin d53c60363b feat: Contact filtering by labels and segments 2024-11-08 15:44:10 +05:30
iamsivin 3d6d6e2afb feat: Import contact 2024-11-08 10:30:14 +05:30
Sivin VargheseandGitHub 430c6d2526 Merge branch 'develop' into feat/contacts-redesign 2024-11-08 10:16:07 +05:30
iamsivin 8320ecd92b feat: Create contact 2024-11-08 02:22:22 +05:30
iamsivin ecd8b265bc feat: Contact sort 2024-11-08 01:27:51 +05:30
iamsivin c5974f97cd chore: Update the usage with camel case 2024-11-07 20:46:29 +05:30
iamsivin 314a33e053 chore: Use camel case 2024-11-07 10:32:38 +05:30
iamsivin 4b85a9045a chore: Minor fix 2024-11-06 23:22:52 +05:30
iamsivin d41857abc4 feat: Contact search 2024-11-06 23:20:00 +05:30
iamsivin 816c65df2d chore: Sort labels 2024-11-06 23:04:53 +05:30
iamsivin 91ee5fc2f0 chore: Form validations 2024-11-06 21:35:05 +05:30
iamsivin 28cbdb6133 feat: Contact merge 2024-11-06 21:08:14 +05:30
iamsivin 703b36e38c chore: Add or remove labels 2024-11-06 18:42:35 +05:30
iamsivin 51cf4df3e3 chore: Code clean up 2024-11-06 18:08:14 +05:30
Sivin VargheseandGitHub caaf8d55c6 Merge branch 'develop' into feat/contacts-redesign 2024-11-06 17:52:52 +05:30
iamsivin d5a1c7f49b chore: Fix build 2024-11-06 17:51:42 +05:30
iamsivin db6f08c12b feat: Contact history 2024-11-06 17:51:03 +05:30
iamsivin 7021e96ad4 chore: More changes 2024-11-06 17:34:04 +05:30
iamsivin c254c44994 chore: Minor fix 2024-11-06 00:06:02 +05:30
iamsivin 05b76f7182 chore: Fix build 2024-11-06 00:03:16 +05:30
iamsivin ae1c6e3f98 chore: Replace thumbnail component with avatar 2024-11-05 23:56:11 +05:30
iamsivin 3c58d6af59 chore: Minor fix 2024-11-05 21:35:19 +05:30
iamsivin fa8a7db612 feat: COntact sidebar 2024-11-05 21:34:05 +05:30
iamsivin 759ce1229d chore: Minor color fix 2024-11-04 23:37:13 +05:30
iamsivin b92805933f chore: fix build 2024-11-04 23:26:04 +05:30
iamsivin b71a05f790 feat: Contact details screen 2024-11-04 19:46:02 +05:30
iamsivin dd9e9286b3 feat: Contact card with expand view 2024-11-04 15:15:17 +05:30
iamsivin f341d159c2 feat: Route view 2024-11-04 15:14:43 +05:30
iamsivin a7bf762bcd feat: Contacts layout 2024-11-04 15:14:01 +05:30
210 changed files with 2653 additions and 7011 deletions
-2
View File
@@ -57,5 +57,3 @@ 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', '>= 1.48.0', require: false
gem 'google-cloud-storage', require: false
gem 'image_processing'
##-- gems for database --#
+32 -31
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.5)
digest-crc (0.6.4)
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.15.1)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
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)
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-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 (2.2.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
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.52.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (~> 0.13)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
googleauth (>= 0.16.2, < 2.a)
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.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
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.23.1)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -462,6 +462,7 @@ 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)
@@ -471,7 +472,7 @@ GEM
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
mini_portile2 (2.8.7)
minitest (5.25.1)
mock_redis (0.36.0)
ruby2_keywords
@@ -479,7 +480,6 @@ 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.8)
nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.8-arm64-darwin)
nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.8-x86_64-darwin)
nokogiri (1.16.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.8-x86_64-linux)
nokogiri (1.16.7-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.1)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
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)
nokogiri (~> 1.14)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
@@ -824,6 +824,7 @@ 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)
@@ -885,7 +886,7 @@ DEPENDENCIES
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-storage (>= 1.48.0)
google-cloud-storage
google-cloud-translate-v3 (>= 0.7.0)
groupdate
grpc
+2 -2
View File
@@ -48,7 +48,7 @@
"size": "basic"
}
},
"stack": "heroku-24",
"stack": "heroku-20",
"image": "heroku/ruby",
"addons": [
{
@@ -58,7 +58,7 @@
"plan": "heroku-postgresql:essential-0"
}
],
"stack": "heroku-24",
"stack": "heroku-20",
"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, :show, :update]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
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?
@user.reload
head :ok
end
def auto_offline
-8
View File
@@ -27,14 +27,6 @@ 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-n-weak rounded-md shadow-none;
@apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none;
}
.mx-input:disabled,
@@ -39,7 +39,7 @@
}
.mx-datepicker-main {
@apply border-0 bg-n-solid-2 rounded-xl;
@apply border-0 bg-white dark:bg-slate-800;
.cell {
&.disabled {
@@ -53,14 +53,6 @@
}
}
.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-[960px] mx-auto">
<div class="w-full max-w-[900px] 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-[960px] mx-auto py-4">
<div class="w-full max-w-[900px] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -0,0 +1,46 @@
<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,15 +1,15 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore, 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,10 +35,20 @@ const exportContacts = async () => {
query = filterQueryGenerator(appliedFilters.value);
}
emit('export', {
...query,
label: route.params.label || '',
});
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')
);
}
};
const handleDialogConfirm = async () => {
@@ -1,13 +1,15 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore, 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);
@@ -18,7 +20,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();
@@ -48,7 +50,16 @@ const handleRemoveFile = () => {
const uploadFile = async () => {
if (!hasSelectedFile.value) return;
emit('import', hasSelectedFile.value);
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);
}
};
defineExpose({ dialogRef });
@@ -72,7 +83,7 @@ defineExpose({ dialogRef });
:href="csvUrl"
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
download="import-contacts-sample"
class="text-n-blue-text"
>
{{
@@ -81,54 +92,57 @@ defineExpose({ dialogRef });
</a>
</p>
</template>
<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">
<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>
<Button
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')"
v-if="!hasSelectedFile"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
"
icon="i-lucide-download"
color="slate"
variant="ghost"
size="sm"
class="!w-fit"
@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 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>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="text/csv"
class="hidden"
@change="handleFileChange"
/>
<input
ref="fileInput"
type="file"
accept="text/csv"
class="hidden"
@change="handleFileChange"
/>
</template>
</Dialog>
</template>
@@ -4,8 +4,9 @@ import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['add', 'import', 'export']);
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 { t } = useI18n();
@@ -29,15 +30,19 @@ 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') {
emit('add');
createNewContactDialogRef.value?.dialogRef.open();
} else if (action === 'import') {
emit('import');
contactImportDialogRef.value?.dialogRef.open();
} else if (action === 'export') {
emit('export');
contactExportDialogRef.value?.dialogRef.open();
}
};
</script>
@@ -55,8 +60,11 @@ const handleContactAction = ({ action }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
class="right-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
<CreateNewContactDialog ref="createNewContactDialogRef" />
<ContactImportDialog ref="contactImportDialogRef" />
<ContactExportDialog ref="contactExportDialogRef" />
</template>
@@ -1,7 +1,6 @@
<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';
@@ -17,9 +16,7 @@ const props = defineProps({
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
@@ -70,7 +67,6 @@ const orderingMenus = [
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
@@ -109,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
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"
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"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -1,12 +1,10 @@
<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 },
@@ -16,108 +14,48 @@ 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 => {
Object.assign(contactData.value, updatedData);
emit('updateContact', { id: props.id, updatedData });
};
const handleUpdateContact = () => {
emit('updateContact', contactData.value);
const onClickViewDetails = async () => {
emit('showContact', props.id);
};
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-start flex-1 gap-4">
<div class="flex items-center justify-between gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<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">
<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">
{{ name }}
</span>
<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
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>
</div>
<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">
<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">
{{ phoneNumber }}
</span>
<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" />
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
@@ -134,7 +72,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
@click="emit('toggle')"
/>
<template #after>
@@ -142,28 +80,22 @@ const onClickViewDetails = () => emit('showContact', props.id);
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-[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]"
enter-to-class="opacity-100 max-h-[360px]"
leave-from-class="opacity-100 max-h-[360px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
>
<div v-show="isExpanded" class="w-full">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<div class="p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
:contact-data="{
id,
name,
email,
phoneNumber,
additionalAttributes,
}"
@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>
@@ -1,89 +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 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>
@@ -98,7 +98,7 @@ const { t } = useI18n();
:size="32"
rounded-full
/>
<div class="flex flex-col w-full min-w-0 gap-1">
<div class="flex flex-col 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 = {
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',
INSTAGRAM: 'i-ri-instagram-line',
LINKEDIN: 'i-ri-linkedin-box-fill',
TWITTER: 'i-ri-twitter-x-fill',
};
const defaultState = {
@@ -80,8 +80,6 @@ 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
@@ -94,13 +92,14 @@ const prepareStateBasedOnProps = () => {
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name);
const [firstName = '', lastName = ''] = name.split(' ');
const {
description = '',
companyName = '',
countryCode = '',
country = '',
city = '',
description,
companyName,
countryCode,
country,
city,
socialProfiles = {},
} = additionalAttributes || {};
@@ -204,20 +203,6 @@ 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,
@@ -226,9 +211,6 @@ watch(() => props.contactData, prepareStateBasedOnProps, {
// Expose state to parent component for avatar upload
defineExpose({
state,
resetValidation,
isFormInvalid,
resetForm,
});
</script>
@@ -238,7 +220,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-1 gap-4 sm:grid-cols-2">
<div class="grid w-full grid-cols-2 gap-4">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
@@ -252,7 +234,7 @@ defineExpose({
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView,
}"
@update:model-value="handleCountrySelection"
@update:model-value="emit('update', state)"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
@@ -266,7 +248,7 @@ defineExpose({
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
!isDetailsView ? '[&:not(.error)]:!border-transparent' : ''
}`"
class="w-full"
@input="
@@ -1,18 +1,16 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore, 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');
@@ -24,11 +22,7 @@ const createNewContact = contactItem => {
const handleDialogConfirm = async () => {
if (!contact.value) return;
emit('create', contact.value);
};
const onSuccess = () => {
contactsFormRef.value?.resetForm();
await store.dispatch('contacts/create', contact.value);
dialogRef.value.close();
};
@@ -36,16 +30,12 @@ const closeDialog = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef, contactsFormRef, onSuccess });
defineExpose({ dialogRef });
</script>
<template>
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
<ContactsForm
ref="contactsFormRef"
is-new-contact
@update="createNewContact"
/>
<ContactsForm is-new-contact @update="createNewContact" />
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
@@ -1,71 +0,0 @@
<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>
@@ -1,43 +0,0 @@
<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>
@@ -1,143 +0,0 @@
<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>
@@ -1,306 +0,0 @@
<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>
@@ -1,69 +0,0 @@
<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>
@@ -1,106 +0,0 @@
<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>
@@ -0,0 +1,209 @@
<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>
@@ -1,103 +0,0 @@
<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,
customAttributes: {
custom_attributes: {
[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-2"
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-1"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
@@ -16,50 +16,61 @@ const { t } = useI18n();
const searchQuery = ref('');
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
const attributes = useMapGetter('attributes/getAttributesByModelType');
const contactAttributes = computed(() => attributes.value('contact_attribute'));
const hasContactAttributes = computed(
() => contactAttributes.value?.length > 0
);
const processContactAttributes = (
attributes,
customAttributes,
filterCondition
) => {
if (!attributes.length || !customAttributes) {
return [];
}
return attributes.reduce((result, attribute) => {
const { attributeKey } = attribute;
const meetsCondition = filterCondition(attributeKey, customAttributes);
if (meetsCondition) {
result.push({
...attribute,
value: customAttributes[attributeKey] ?? '',
});
}
return result;
}, []);
};
// Convert attribute key value from snake_case to camelCase
const toCamelCase = str =>
str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
const usedAttributes = computed(() => {
return processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => key in custom
);
if (!contactAttributes.value || !props.selectedContact?.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)
] ?? '',
}));
});
const unusedAttributes = computed(() => {
return processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => !(key in custom)
);
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)
] ?? '',
}));
});
const filteredUnusedAttributes = computed(() => {
@@ -34,19 +34,22 @@ const contactConversations = computed(() =>
>
<Spinner />
</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
<div v-else-if="contactConversations.length > 0" class="flex flex-col py-6">
<div
v-for="conversation in contactConversations"
:key="conversation.id"
: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"
/>
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>
</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', 'resetTab']);
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
@@ -74,9 +74,6 @@ 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 py-2 mx-6 border-b border-n-strong group/note"
class="flex flex-col gap-2 px-6 py-2 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">
@@ -1,28 +0,0 @@
<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,8 +26,6 @@ defineProps({
},
});
const emit = defineEmits(['create']);
const createNewContactDialogRef = ref(null);
const onClick = () => {
@@ -56,11 +54,8 @@ 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,9 +1,10 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useStore } 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';
@@ -30,55 +31,41 @@ const avatarUrl = ref('');
const contactsFormRef = ref(null);
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const contactData = ref({});
const getInitialContactData = () => {
const selectedContactData = computed(() => {
if (!props.selectedContact) return {};
return { ...props.selectedContact };
};
onMounted(() => {
Object.assign(contactData.value, getInitialContactData());
return props.selectedContact;
});
const createdAt = computed(() => {
return contactData.value?.createdAt
? dynamicTime(contactData.value.createdAt)
return props.selectedContact?.createdAt
? dynamicTime(props.selectedContact.createdAt)
: '';
});
const lastActivityAt = computed(() => {
return contactData.value?.lastActivityAt
? dynamicTime(contactData.value.lastActivityAt)
return props.selectedContact?.lastActivityAt
? dynamicTime(props.selectedContact.lastActivityAt)
: '';
});
const avatarSrc = computed(() => {
return avatarUrl.value ? avatarUrl.value : contactData.value?.thumbnail;
return avatarUrl.value ? avatarUrl.value : props.selectedContact?.thumbnail;
});
const handleFormUpdate = updatedData => {
Object.assign(contactData.value, updatedData);
const updateContact = async updatedData => {
await store.dispatch('contacts/update', updatedData);
await store.dispatch(
'contacts/fetchContactableInbox',
props.selectedContact?.id
);
};
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 handleFormUpdate = debounce(
updatedData => updateContact(updatedData),
600,
false
);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
@@ -89,7 +76,7 @@ const handleAvatarUpload = async ({ file, url }) => {
avatarUrl.value = url;
try {
await store.dispatch('contacts/update', {
await updateContact({
...contactsFormRef.value?.state,
avatar: file,
isFormData: true,
@@ -108,7 +95,6 @@ const handleAvatarDelete = async () => {
}
avatarFile.value = null;
avatarUrl.value = '';
contactData.value.thumbnail = null;
} catch (error) {
useAlert(
error.message
@@ -124,7 +110,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"
@@ -132,48 +118,26 @@ 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>
<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>
<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>
<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"
/>
<ContactLabels :contact-id="selectedContact.id" />
</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,58 +1,54 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { debounce } from '@chatwoot/utils';
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();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
// Manage expanded state here
const expandedCardId = ref(null);
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 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 onClickViewDetails = async id => {
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 dynamicRouteName =
ROUTE_MAPPINGS[route.name] || 'contacts_dashboard_edit_index';
await router.push({ name, params, query: route.query });
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 });
};
const toggleExpanded = id => {
@@ -61,7 +57,7 @@ const toggleExpanded = id => {
</script>
<template>
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<div class="flex flex-col gap-4">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
@@ -72,9 +68,8 @@ 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="updateContact"
@update-contact="handleFormUpdate"
@show-contact="onClickViewDetails"
/>
</div>
@@ -1,7 +1,6 @@
<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';
@@ -14,15 +13,9 @@ const props = defineProps({
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage.content || t('CHAT_LIST.NO_CONTENT')
);
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
});
const assignee = computed(() => {
@@ -1,7 +1,6 @@
<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';
@@ -20,15 +19,9 @@ const props = defineProps({
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage.content || t('CHAT_LIST.NO_CONTENT')
);
const { lastNonActivityMessage = {} } = props.conversation;
return 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-all duration-300 ease-in-out cursor-pointer"
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out cursor-pointer rounded-xl"
@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': isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
@@ -127,8 +127,9 @@ 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': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
'text-n-slate-12 truncate flex-1':
isEditingView && !isAttributeTypeLink,
'truncate flex-1 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-11 dark:text-n-slate-11;
@apply text-n-slate-10 dark:text-n-slate-10;
}
}
}
@@ -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-[960px] mx-auto">
<div class="w-full max-w-[900px] mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -76,7 +76,6 @@ 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"
@@ -96,7 +95,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3">
<div class="w-full max-w-[900px] mx-auto py-3">
<slot name="content" />
</div>
</main>
@@ -182,7 +182,6 @@ onMounted(() => {
<OnClickOutside @trigger="openAgentsList = false">
<Button
variant="ghost"
color="slate"
class="!px-0 font-normal hover:!bg-transparent"
text-variant="info"
@click="openAgentsList = !openAgentsList"
@@ -215,7 +214,6 @@ onMounted(() => {
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
class="!px-2 font-normal hover:!bg-transparent"
@click="openCategoryList = !openCategoryList"
>
@@ -246,7 +244,6 @@ 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,7 +66,6 @@ onMounted(() => {
icon="i-lucide-x"
size="sm"
variant="ghost"
color="slate"
class="hover:text-n-slate-11"
@click="emit('close')"
/>
@@ -108,7 +108,6 @@ 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"
@@ -134,7 +133,6 @@ 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 { vOnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
@@ -12,29 +12,21 @@ import {
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
});
const route = useRoute();
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);
@@ -42,19 +34,12 @@ 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 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 uiFlags = useMapGetter('contactConversations/getUIFlags');
const contactId = computed(() => route.params.contactId || null);
const activeContact = computed(() => contactById.value(contactId.value));
const onContactSearch = debounce(
async query => {
@@ -73,12 +58,9 @@ const onContactSearch = debounce(
false
);
const resetContacts = () => {
contacts.value = [];
};
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
@@ -92,6 +74,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
contact = rest;
}
selectedContact.value = contact;
if (contact?.id) {
isFetchingInboxes.value = true;
try {
@@ -106,7 +89,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
resetContacts();
onContactSearch('');
};
const clearSelectedContact = () => {
@@ -118,7 +101,6 @@ const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
targetInbox.value = null;
resetContacts();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
@@ -152,7 +134,8 @@ const toggle = () => {
watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
if (activeContact.value && contactId.value) {
// Add null check for contactInboxes
const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = {
...activeContact.value,
@@ -163,7 +146,9 @@ watch(
{ immediate: true, deep: true }
);
onMounted(() => resetContacts());
onMounted(() => {
onContactSearch('');
});
const keyboardEvents = {
Escape: {
@@ -174,20 +159,17 @@ const keyboardEvents = {
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
v-on-click-outside="() => (showComposeNewConversation = false)"
class="relative z-40"
>
<div class="relative">
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
v-if="showComposeNewConversation"
:contacts="contacts"
@@ -201,9 +183,7 @@ 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,7 +4,6 @@ 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';
@@ -114,6 +113,7 @@ 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-click-outside="() => (isEmojiPickerOpen = false)"
v-on-clickaway="() => (isEmojiPickerOpen = false)"
class="relative"
>
<Button
@@ -0,0 +1,170 @@
<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,6 +59,8 @@ 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,
@@ -141,11 +143,7 @@ const isAnyDropdownActive = computed(() => {
});
const handleContactSearch = value => {
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email', 'phone_number', 'name'],
query: value,
});
emit('searchContacts', value);
};
const handleDropdownUpdate = (type, value) => {
@@ -158,14 +156,18 @@ const handleDropdownUpdate = (type, value) => {
}
};
const toggleBccInput = () => {
showBccInput.value = !showBccInput.value;
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
emit('searchContacts', value);
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
emit('searchContacts', value);
};
const setSelectedContact = async ({ value, action, ...rest }) => {
@@ -254,7 +256,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
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"
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"
>
<ContactSelector
:contacts="contacts"
@@ -292,10 +294,12 @@ 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,6 +4,7 @@ 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,
@@ -42,19 +43,20 @@ 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: email ? `${name} (${email})` : name,
label: `${name} (${email})`,
value: id,
thumbnail: { name, src: thumbnail },
...rest,
@@ -65,20 +67,7 @@ const contactsList = computed(() => {
});
const selectedContactLabel = computed(() => {
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'
: '';
return `${props.selectedContact?.name} (${props.selectedContact?.email})`;
});
</script>
@@ -86,7 +75,7 @@ const errorClass = 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(`${i18nPrefix}.LABEL`) }}
{{ t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.LABEL') }}
</label>
<div
@@ -94,7 +83,9 @@ const errorClass = 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(`${i18nPrefix}.CONTACT_CREATING`) }}
{{
t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING')
}}
</span>
</div>
<div
@@ -104,7 +95,9 @@ const errorClass = computed(() => {
<span class="text-sm truncate text-n-slate-12">
{{
isCreatingContact
? t(`${i18nPrefix}.CONTACT_CREATING`)
? t(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING'
)
: selectedContactLabel
}}
</span>
@@ -119,7 +112,11 @@ const errorClass = computed(() => {
</div>
<TagInput
v-else
:placeholder="t(`${i18nPrefix}.TAG_INPUT_PLACEHOLDER`)"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.TAG_INPUT_PLACEHOLDER'
)
"
mode="single"
:menu-items="contactsList"
:show-dropdown="showContactsDropdown"
@@ -128,8 +125,12 @@ const errorClass = computed(() => {
allow-create
type="email"
class="flex-1 min-h-7"
:class="errorClass"
focus-on-mount
:class="
hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: ''
"
@focus="emit('updateDropdown', 'contacts', true)"
@input="emit('searchContacts', $event)"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
@@ -10,6 +10,7 @@ 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 },
});
@@ -17,17 +18,10 @@ 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: '' });
@@ -62,12 +56,6 @@ 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>
@@ -75,22 +63,30 @@ const inputClass = computed(() => {
<div class="flex items-baseline flex-1 w-full h-8 gap-3 px-4 py-3">
<InlineInput
v-model="subject"
:placeholder="t(`${i18nPrefix}.SUBJECT_PLACEHOLDER`)"
:label="t(`${i18nPrefix}.SUBJECT_LABEL`)"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.SUBJECT_PLACEHOLDER')
"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.SUBJECT_LABEL')"
focus-on-mount
:custom-input-class="inputClass"
:custom-input-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
/>
</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(`${i18nPrefix}.CC_LABEL`) }}
{{ t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.CC_LABEL') }}
</label>
<div class="flex items-center w-full gap-3 min-h-7">
<TagInput
:model-value="ccEmailsArray"
:placeholder="t(`${i18nPrefix}.CC_PLACEHOLDER`)"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.CC_PLACEHOLDER')
"
:menu-items="contactEmailsList"
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
@@ -102,12 +98,12 @@ const inputClass = computed(() => {
@update:model-value="handleCcUpdate"
/>
<Button
:label="t(`${i18nPrefix}.BCC_BUTTON`)"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_BUTTON')"
variant="ghost"
size="sm"
color="slate"
class="flex-shrink-0"
@click="toggleBccInput"
@click="emit('toggleBcc')"
/>
</div>
</div>
@@ -118,11 +114,13 @@ const inputClass = computed(() => {
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t(`${i18nPrefix}.BCC_LABEL`) }}
{{ t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_LABEL') }}
</label>
<TagInput
:model-value="bccEmailsArray"
:placeholder="t(`${i18nPrefix}.BCC_PLACEHOLDER`)"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_PLACEHOLDER')
"
:menu-items="contactEmailsList"
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
@@ -1,7 +1,6 @@
<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';
@@ -54,7 +53,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 truncate text-n-slate-12">
<span class="text-sm text-n-slate-12">
{{ targetInboxLabel }}
</span>
<Button
@@ -68,7 +67,7 @@ const targetInboxLabel = computed(() => {
</div>
<div
v-else
v-on-click-outside="() => emit('toggleDropdown', false)"
v-on-clickaway="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Button
@@ -1,92 +0,0 @@
<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>
@@ -1,168 +0,0 @@
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,15 +1,10 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
const CHANNEL_PRIORITY = {
'Channel::Email': 1,
'Channel::Whatsapp': 2,
'Channel::Sms': 3,
'Channel::TwilioSms': 4,
'Channel::WebWidget': 5,
'Channel::Api': 6,
export const convertChannelTypeToLabel = channelType => {
const [, type] = channelType.split('::');
return type ? type.charAt(0).toUpperCase() + type.slice(1) : channelType;
};
export const generateLabelForContactableInboxesList = ({
@@ -27,52 +22,29 @@ export const generateLabelForContactableInboxesList = ({
) {
return `${name} (${phoneNumber})`;
}
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);
return `${name} (${convertChannelTypeToLabel(channelType)})`;
};
export const buildContactableInboxesList = contactInboxes => {
if (!contactInboxes) return [];
return contactInboxes.map(transformInbox).sort(compareInboxes);
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,
})
);
};
export const getCapitalizedNameFromEmail = email => {
@@ -159,38 +131,30 @@ export const prepareWhatsAppMessagePayload = ({
};
};
export const generateContactQuery = ({ keys = ['email'], query }) => {
export const generateContactQuery = ({ query }) => {
return {
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
custom_attribute_type: '',
},
],
};
};
// API Calls
export const searchContacts = async ({ keys, query }) => {
export const searchContacts = async query => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ keys, query })
generateContactQuery({ query })
);
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 || [];
return camelcaseKeys(payload, { deep: true });
};
export const createNewContact = async email => {
@@ -6,6 +6,19 @@ 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',
@@ -46,7 +59,7 @@ describe('composeConversationHelper', () => {
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe');
).toBe('John Doe (Api)');
});
});
@@ -70,7 +83,6 @@ 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,
@@ -199,13 +211,12 @@ describe('composeConversationHelper', () => {
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
expect(helpers.generateContactQuery({ query })).toEqual(expected);
});
it('handles empty query', () => {
@@ -216,40 +227,12 @@ describe('composeConversationHelper', () => {
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
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);
expect(helpers.generateContactQuery({ query: '' })).toEqual(expected);
});
});
@@ -270,10 +253,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
const result = await helpers.searchContacts('john');
expect(result).toEqual([
{
@@ -285,80 +265,11 @@ describe('composeConversationHelper', () => {
},
]);
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',
},
],
});
expect(ContactAPI.filter).toHaveBeenCalledWith(
undefined,
'name',
helpers.generateContactQuery({ query: 'john' })
);
});
it('handles empty search results', async () => {
@@ -374,8 +285,6 @@ describe('composeConversationHelper', () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
phone_number: '+1234567890',
contact_inboxes: [
{
inbox_id: 1,
@@ -398,8 +307,6 @@ describe('composeConversationHelper', () => {
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
phoneNumber: '+1234567890',
contactInboxes: [
{
inboxId: 1,
@@ -463,74 +370,3 @@ 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: `${Math.min(props.size / 2.5, 24)}px`,
fontSize: `${props.size / 2}px`,
}));
const invalidateCurrentImage = () => {
@@ -114,13 +114,8 @@ 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" color="slate" />
<Button
label="Ghost with Icon"
variant="ghost"
color="slate"
icon="i-lucide-plus"
/>
<Button label="Ghost Button" variant="ghost" />
<Button label="Ghost with Icon" variant="ghost" icon="i-lucide-plus" />
<Button label="Link Button" variant="link" />
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
</div>
@@ -88,7 +88,6 @@ 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: {
@@ -96,7 +95,6 @@ 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: {
@@ -105,7 +103,6 @@ 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:
@@ -114,7 +111,6 @@ 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',
@@ -122,7 +118,6 @@ 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: {
@@ -156,7 +151,7 @@ const STYLE_CONFIG = {
const variantClasses = computed(() => {
const variantMap = {
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
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, index) in filteredMenuItems"
:key="index"
v-for="item in filteredMenuItems"
:key="item.action"
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 size-3.5" />
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<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: () => console.log('Contact Support'),
click: () => window.alert('Contact Support'),
},
{
label: 'Keyboard Shortcuts',
icon: 'i-lucide-keyboard',
click: () => console.log('Keyboard Shortcuts'),
click: () => window.alert('Keyboard Shortcuts'),
},
{
label: 'Profile Settings',
icon: 'i-lucide-user-pen',
click: () => console.log('Profile Settings'),
click: () => window.alert('Profile Settings'),
},
{
label: 'Change Appearance',
icon: 'i-lucide-swatch-book',
click: () => console.log('Change Appearance'),
click: () => window.alert('Change Appearance'),
},
{
label: 'Open SuperAdmin',
@@ -40,7 +40,7 @@ const menuItems = ref([
{
label: 'Log Out',
icon: 'i-lucide-log-out',
click: () => console.log('Log Out'),
click: () => window.alert('Log Out'),
},
]);
</script>
@@ -1,33 +1,7 @@
<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 rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base relative"
:class="[borderClass, beforeClass]"
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"
>
<slot />
</ul>
@@ -21,9 +21,9 @@ provideDropdownContext({
</script>
<template>
<div v-on-click-outside="closeMenu" class="relative space-y-2">
<div class="relative space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" class="absolute">
<div v-if="isOpen" v-on-click-outside="closeMenu" 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>
@@ -1,66 +0,0 @@
<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>
@@ -1,109 +0,0 @@
<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>
@@ -1,68 +0,0 @@
<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>
@@ -1,201 +0,0 @@
<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>
@@ -1,174 +0,0 @@
<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>
@@ -1,175 +0,0 @@
<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>
@@ -1,130 +0,0 @@
<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>
@@ -1,184 +0,0 @@
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 };
}
@@ -1,603 +0,0 @@
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',
},
];
@@ -1,49 +0,0 @@
/**
* 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('_', ' ') ?? '';
@@ -1,137 +0,0 @@
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-50" strong>
<DropdownBody class="top-0 min-w-48 z-[999]">
<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-50" strong>
<DropdownBody class="top-0 min-w-48 z-[999]">
<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-50" strong>
<DropdownBody class="top-0 min-w-56 z-[999]">
<div v-if="!disableSearch" class="relative">
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
@@ -1,164 +0,0 @@
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,
};
}
@@ -1,243 +0,0 @@
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', 'focus']);
const emit = defineEmits(['enterPress', 'input', 'blur']);
const modelValue = defineModel({
type: [String, Number],
@@ -58,10 +58,6 @@ const handleBlur = event => {
emit('blur', event.target.value);
};
const handleFocus = event => {
emit('focus', event.target.value);
};
onMounted(() => {
nextTick(() => {
if (props.focusOnMount) {
@@ -99,7 +95,6 @@ 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,7 +75,6 @@ const pageInfo = computed(() => {
icon="i-lucide-chevrons-left"
variant="ghost"
size="sm"
color="slate"
class="!w-8 !h-6"
:disabled="isFirstPage"
@click="changePage(1)"
@@ -83,7 +82,6 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevron-left"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isFirstPage"
@@ -98,7 +96,6 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevron-right"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@@ -107,7 +104,6 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevrons-right"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@@ -91,18 +91,13 @@ 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) {
if (hasError.value) return errorClass;
return `border-transparent ${focusClass}`;
return hasError.value ? errorClass : 'border-transparent';
}
if (hasError.value) {
return errorClass;
}
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`;
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';
});
const phoneNumberError = computed(() => {
@@ -7,17 +7,18 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
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',
@@ -27,6 +28,7 @@ const { accountScopedRoute } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const enableNewConversation = false;
const toggleShortcutModalFn = show => {
if (show) {
@@ -44,7 +46,7 @@ useSidebarKeyboardShortcuts(toggleShortcutModalFn);
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
sessionStorage
localStorage
);
const setExpandedItem = name => {
@@ -58,7 +60,11 @@ 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'
);
@@ -77,6 +83,13 @@ 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 [
{
@@ -188,63 +201,119 @@ const menuItems = computed(() => {
},
],
},
{
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'],
!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: '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'),
@@ -265,6 +334,11 @@ 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'),
@@ -290,11 +364,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.REPORTS_SLA'),
to: accountScopedRoute('sla_reports'),
},
{
name: 'Reports Bot',
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
],
},
{
@@ -480,7 +549,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 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
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"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
@@ -492,17 +561,14 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<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>
<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>
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
@@ -520,6 +586,12 @@ 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 z-50">
<DropdownBody class="min-w-80">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
<DropdownItem
v-for="account in currentUser.accounts"
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, nextTick } from 'vue';
import { computed } from 'vue';
import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
@@ -113,13 +113,6 @@ 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="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
<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 preserve-open>
<DropdownItem>
<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 z-20">
<DropdownBody class="min-w-32">
<DropdownItem
v-for="status in availabilityStatuses"
:key="status.value"
@@ -1,11 +1,9 @@
<script setup>
import { ref, computed, watch } from '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';
import { email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
const props = defineProps({
placeholder: { type: String, default: '' },
@@ -81,19 +79,17 @@ const filteredMenuItems = computed(() => {
item => !tags.value.includes(item.label)
);
// Show typed value as suggestion only if:
// Only show typed value as suggestion if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Email validation passes (if type is email) and There are no menu items available
const trimmedNewTag = newTag.value?.trim();
const shouldShowTypedValue =
// 3. There are no menu items available
const trimmedNewTag = newTag.value.trim();
if (
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!props.isLoading &&
!availableMenuItems.length &&
(props.type === 'email' ? !isNewTagInValidType.value : true);
if (shouldShowTypedValue) {
!props.isLoading
) {
return [
{
label: trimmedNewTag,
@@ -119,7 +115,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) {
@@ -187,7 +183,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();
}
}
@@ -199,7 +195,7 @@ const handleBlur = e => emit('blur', e);
<template>
<div
v-on-click-outside="() => handleClickOutside()"
v-on-clickaway="() => handleClickOutside()"
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@@ -218,11 +214,9 @@ const handleBlur = e => emit('blur', e);
@click.stop="removeTag(index)"
/>
</div>
<div
v-if="showInput || showDropdownMenu"
class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full"
>
<div 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 ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.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,15 +37,9 @@ 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';
@@ -57,11 +51,12 @@ 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';
@@ -112,7 +107,7 @@ const unAssignedChatsList = useMapGetter('getUnAssignedChats');
const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const appliedFilters = useMapGetter('getAppliedConversationFilters');
const folders = useMapGetter('customViews/getConversationCustomViews');
const agentList = useMapGetter('agents/getAgents');
const teamsList = useMapGetter('teams/getTeams');
@@ -376,7 +371,6 @@ function emitConversationLoaded() {
}
function fetchFilteredConversations(payload) {
payload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
@@ -389,7 +383,6 @@ function fetchFilteredConversations(payload) {
}
function fetchSavedFilteredConversations(payload) {
payload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
@@ -400,7 +393,6 @@ function fetchSavedFilteredConversations(payload) {
}
function onApplyFilter(payload) {
payload = useSnakeCase(payload);
resetBulkActions();
foldersQuery.value = filterQueryGenerator(payload);
store.dispatch('conversationPage/reset');
@@ -414,11 +406,10 @@ function closeAdvanceFiltersModal() {
}
function onUpdateSavedFilter(payload, folderName) {
const transformedPayload = useSnakeCase(payload);
const payloadData = {
...unref(activeFolder),
name: unref(folderName),
query: filterQueryGenerator(transformedPayload),
query: filterQueryGenerator(payload),
};
store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
@@ -470,19 +461,17 @@ function initializeExistingFilterToModal() {
currentUserDetails.value,
activeAssigneeTab.value
);
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
if (statusFilter) {
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
appliedFilter.value = [...appliedFilter.value, 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];
}
@@ -497,47 +486,27 @@ function initializeFolderToFilterModal(newActiveFolder) {
const query = unref(newActiveFolder)?.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),
setParamsForEditFolderModal()
)
: [];
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values,
};
});
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,
}));
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;
}
@@ -782,7 +751,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
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="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="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
@@ -801,14 +770,12 @@ watch(conversationFilters, (newVal, oldVal) => {
@basic-filter-change="onBasicFilterChange"
/>
<Teleport v-if="showAddFoldersModal" to="#saveFilterTeleportTarget">
<SaveCustomView
v-model="appliedFilter"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
</Teleport>
<AddCustomViews
v-if="showAddFoldersModal"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
<DeleteCustomViews
v-if="showDeleteFoldersModal"
@@ -904,16 +871,22 @@ watch(conversationFilters, (newVal, oldVal) => {
</template>
</DynamicScroller>
</div>
<Teleport v-if="showAdvancedFilters" to="#conversationFilterTeleportTarget">
<ConversationFilter
v-model="appliedFilter"
:folder-name="activeFolderName"
<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"
:is-folder-view="hasActiveFolders"
@apply-filter="onApplyFilter"
@update-folder="onUpdateSavedFilter"
@close="closeAdvanceFiltersModal"
/>
</Teleport>
</woot-modal>
</div>
</template>
@@ -62,18 +62,14 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
</div>
<div class="flex items-center gap-1">
<template v-if="hasAppliedFilters && !hasActiveFolders">
<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.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emit('addFolders')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
@@ -84,21 +80,14 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
/>
</template>
<template v-if="hasActiveFolders">
<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.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emit('filtersModal')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
@@ -108,18 +97,15 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
@click="emit('deleteFolders')"
/>
</template>
<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>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emit('filtersModal')"
/>
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
@change-filter="onBasicFilterChange"
@@ -26,13 +26,7 @@ export default {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
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;
onNewToastMessage({ message, action }) {
this.snackMessages.push({
key: new Date().getTime(),
message,
@@ -40,7 +34,7 @@ export default {
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, duration);
}, this.duration);
},
},
};
@@ -3,20 +3,18 @@ import { frontendURL } from '../../../../helper/URLHelper';
const contacts = accountId => ({
parentNav: 'contacts',
routes: [
'contacts_dashboard_index',
'contacts_dashboard_segments_index',
'contacts_dashboard_labels_index',
'contacts_edit',
'contacts_edit_segment',
'contacts_edit_label',
'contacts_dashboard',
'contact_profile_dashboard',
'contacts_segments_dashboard',
'contacts_labels_dashboard',
],
menuItems: [
{
icon: 'contact-card-group',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index',
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
],
});
@@ -31,7 +31,7 @@ const primaryMenuItems = accountId => [
label: 'CONTACTS',
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard_index',
toStateName: 'contacts_dashboard',
},
{
icon: 'arrow-trending-lines',
@@ -27,7 +27,6 @@ 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: 'labels',
key: 'label',
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}/contacts/labels/${label.title}`
`accounts/${this.accountId}/labels/${label.title}/contacts`
),
})),
};
@@ -194,7 +194,7 @@ export default {
icon: 'folder',
label: 'CUSTOM_VIEWS_SEGMENTS',
hasSubMenu: true,
key: 'segments',
key: 'custom_view',
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/segments/${view.id}`
`accounts/${this.accountId}/contacts/custom_view/${view.id}`
),
})),
};
@@ -247,7 +247,7 @@ export default {
<transition-group
name="menu-list"
tag="ul"
class="pt-2 list-none reset-base"
class="pt-2 reset-base list-none"
>
<SecondaryNavItem
v-for="menuItem in accessibleMenuItems"
@@ -119,13 +119,6 @@ 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);
},
@@ -137,7 +130,6 @@ 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';
@@ -250,7 +242,7 @@ export default {
</span>
</router-link>
<ul v-if="hasSubMenu" class="list-none reset-base">
<ul v-if="hasSubMenu" class="reset-base list-none">
<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-n-slate-11 mb-0">
<p class="text-sm text-gray-700">
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
</p>
</div>
@@ -62,7 +62,7 @@ const end = computed(() => {
<woot-button
:disabled="!table.getCanPreviousPage()"
variant="clear"
class="h-8 border-0 flex items-center"
class="size-8 flex items-center border border-slate-50"
color-scheme="secondary"
@click="table.setPageIndex(0)"
>
@@ -70,7 +70,7 @@ const end = computed(() => {
</woot-button>
<woot-button
variant="clear"
class="h-8 border-0 flex items-center"
class="size-8 flex items-center border border-slate-50"
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="h-8 flex items-center justify-center text-xs leading-none text-center"
:class="page == currentPage ? 'border-n-brand' : 'border-slate-50'"
class="size-8 flex items-center justify-center border text-xs leading-none text-center"
:class="page == currentPage ? 'border-woot-500' : 'border-slate-50'"
color-scheme="secondary"
@click="table.setPageIndex(page - 1)"
>
<span
<div
class="text-center"
:class="{ 'text-n-brand': page == currentPage }"
:class="{ 'text-woot-500': page == currentPage }"
>
{{ page }}
</span>
</div>
</woot-button>
<woot-button
:disabled="!table.getCanNextPage()"
variant="clear"
class="h-8 border-0 flex items-center"
class="size-8 flex items-center border border-slate-50"
color-scheme="secondary"
@click="table.nextPage()"
>
@@ -105,7 +105,7 @@ const end = computed(() => {
<woot-button
:disabled="!table.getCanNextPage()"
variant="clear"
class="h-8 border-0 flex items-center"
class="size-8 flex items-center border border-slate-50"
color-scheme="secondary"
@click="table.setPageIndex(table.getPageCount() - 1)"
>
@@ -1,9 +1,8 @@
<script setup>
import { FlexRender } from '@tanstack/vue-table';
import SortButton from './SortButton.vue';
import { computed } from 'vue';
const props = defineProps({
defineProps({
table: {
type: Object,
required: true,
@@ -12,36 +11,22 @@ const props = 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 bg-n-slate-1">
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
class="rounded-xl"
>
<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">
<th
v-for="header in headerGroup.headers"
:key="header.id"
:style="{
width: `${header.getSize()}px`,
}"
class="text-left py-3 px-5 font-normal text-sm"
:class="headerClass"
class="text-left py-3 px-5 dark:bg-slate-800 text-slate-800 dark:text-slate-200 font-normal text-xs"
@click="header.column.getCanSort() && header.column.toggleSorting()"
>
<div
@@ -58,12 +43,16 @@ const headerClass = computed(() =>
</tr>
</thead>
<tbody class="divide-y divide-n-slate-2">
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<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"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="isRelaxed ? 'py-4 px-5' : 'py-2 px-5'"
class="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